From f9440becf98a699474cd719668e5c3dc685a8e12 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:13:52 +0300 Subject: [PATCH 01/32] feat(shortcuts): redesign new-tab shortcuts hub behind feature flag Replaces the "My shortcuts vs Most visited" toggle with a hybrid hub where users can add, edit, remove and reorder shortcuts directly, enrich them with custom names/icons/accent colors, and import from browser top sites or the bookmarks bar on demand. The hub ships behind the `shortcuts_hub` GrowthBook flag. Legacy code paths and tests are preserved; the spec mocks the flag to false to keep the existing UI covered. Made-with: Cursor --- packages/extension/src/manifest.json | 4 +- .../ShortcutLinks/ShortcutGetStarted.tsx | 40 +- .../ShortcutLinks/ShortcutImportFlow.tsx | 241 +++++++++++ .../ShortcutLinks/ShortcutLinks.spec.tsx | 7 + .../newtab/ShortcutLinks/ShortcutLinks.tsx | 59 ++- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 221 ++++++++++ .../shared/src/components/modals/common.tsx | 32 ++ .../src/components/modals/common/types.ts | 4 + .../shared/src/contexts/SettingsContext.tsx | 45 ++ .../shortcuts/components/AddShortcutTile.tsx | 42 ++ .../shortcuts/components/ShortcutTile.tsx | 246 +++++++++++ .../modals/BookmarksPermissionModal.tsx | 61 +++ .../components/modals/ImportPickerModal.tsx | 167 ++++++++ .../components/modals/ShortcutEditModal.tsx | 221 ++++++++++ .../modals/ShortcutsManageModal.tsx | 403 ++++++++++++++++++ .../shortcuts/contexts/ShortcutsProvider.tsx | 19 + .../shortcuts/hooks/useBrowserBookmarks.ts | 164 +++++++ .../shortcuts/hooks/useShortcutsManager.ts | 339 +++++++++++++++ .../shortcuts/hooks/useShortcutsMigration.ts | 61 +++ .../shared/src/features/shortcuts/types.ts | 34 ++ packages/shared/src/graphql/actions.ts | 1 + packages/shared/src/graphql/settings.ts | 2 + packages/shared/src/lib/featureManagement.ts | 2 + packages/shared/src/lib/links.ts | 23 + packages/shared/src/lib/log.ts | 7 + 25 files changed, 2434 insertions(+), 11 deletions(-) create mode 100644 packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx create mode 100644 packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx create mode 100644 packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx create mode 100644 packages/shared/src/features/shortcuts/components/ShortcutTile.tsx create mode 100644 packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx create mode 100644 packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx create mode 100644 packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx create mode 100644 packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx create mode 100644 packages/shared/src/features/shortcuts/hooks/useBrowserBookmarks.ts create mode 100644 packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts create mode 100644 packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts create mode 100644 packages/shared/src/features/shortcuts/types.ts 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..acf7977335f 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx @@ -5,7 +5,10 @@ import { cloudinaryShortcutsIconsReddit, cloudinaryShortcutsIconsStackoverflow, } from '@dailydotdev/shared/src/lib/image'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; +import { + DownloadIcon, + PlusIcon, +} from '@dailydotdev/shared/src/components/icons'; import { Button, ButtonSize, @@ -19,7 +22,7 @@ import { useActions } from '@dailydotdev/shared/src/hooks'; function ShortcutItemPlaceholder({ children }: PropsWithChildren) { return (
-
+
{children}
@@ -30,11 +33,13 @@ function ShortcutItemPlaceholder({ children }: PropsWithChildren) { interface ShortcutGetStartedProps { onTopSitesClick: () => void; onCustomLinksClick: () => void; + onImportClick?: () => void; } export const ShortcutGetStarted = ({ onTopSitesClick, onCustomLinksClick, + onImportClick, }: ShortcutGetStartedProps): ReactElement => { const { githubShortcut } = useThemedAsset(); const { completeAction, checkHasCompleted } = useActions(); @@ -55,11 +60,19 @@ export const ShortcutGetStarted = ({ }; return ( -
-

- Choose your most visited sites -

-
+
+
+
+
+

+ Choose your most visited sites +

+

+ Pin the sites you hit every day. Add your own, or import from your + browser in a click. +

+
+
{items.map((url) => (
-
+
+ {onImportClick && ( + + )} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx new file mode 100644 index 00000000000..2eeeb80385b --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -0,0 +1,241 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +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 { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; + +// Coordinates the "Import from browser" / "Import from bookmarks" flows for +// the new hub. Keeps the permission modals, picker modal, and silent-import +// paths in one place so the hub UI itself stays declarative. +export function ShortcutImportFlow(): ReactElement | null { + const { + showImportSource, + setShowImportSource, + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + bookmarks, + hasCheckedBookmarksPermission, + askBookmarksPermission, + } = useShortcuts(); + const { customLinks } = useSettingsContext(); + const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); + const { openModal } = useLazyModal(); + + // Prevents running the same import more than once for a single click. + const handledRef = useRef(null); + + useEffect(() => { + if (!showImportSource) { + handledRef.current = null; + return; + } + + const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); + + if (showImportSource === 'topSites') { + if (!hasCheckedTopSitesPermission || topSites === undefined) { + return; + } + if (handledRef.current === 'topSites') { + return; + } + handledRef.current = 'topSites'; + + if (topSites.length === 0) { + displayToast('No top sites yet. Visit some sites and try again.'); + setShowImportSource?.(null); + return; + } + if (capacity === 0) { + displayToast( + `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, + ); + setShowImportSource?.(null); + return; + } + + const items = topSites.map((s) => ({ url: s.url })); + if (items.length <= capacity) { + manager + .importFrom('topSites', items) + .then((result) => { + displayToast( + `Imported ${result.imported} sites to shortcuts${ + result.skipped ? `. ${result.skipped} skipped.` : '' + }`, + ); + }) + .finally(() => { + setShowImportSource?.(null); + }); + return; + } + openModal({ + type: LazyModal.ImportPicker, + props: { source: 'topSites', items }, + }); + setShowImportSource?.(null); + return; + } + + if (showImportSource === 'bookmarks') { + if (!hasCheckedBookmarksPermission || bookmarks === undefined) { + return; + } + if (handledRef.current === 'bookmarks') { + return; + } + handledRef.current = 'bookmarks'; + + if (bookmarks.length === 0) { + displayToast( + 'Your bookmarks bar is empty. Add some bookmarks and try again.', + ); + setShowImportSource?.(null); + return; + } + if (capacity === 0) { + displayToast( + `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, + ); + setShowImportSource?.(null); + return; + } + + const items = bookmarks.map((b) => ({ url: b.url, title: b.title })); + if (items.length <= capacity) { + manager + .importFrom('bookmarks', items) + .then((result) => { + displayToast( + `Imported ${result.imported} bookmarks to shortcuts${ + result.skipped ? `. ${result.skipped} skipped.` : '' + }`, + ); + }) + .finally(() => { + setShowImportSource?.(null); + }); + return; + } + openModal({ + type: LazyModal.ImportPicker, + props: { source: 'bookmarks', items }, + }); + setShowImportSource?.(null); + } + }, [ + showImportSource, + topSites, + hasCheckedTopSitesPermission, + bookmarks, + hasCheckedBookmarksPermission, + customLinks, + manager, + displayToast, + openModal, + setShowImportSource, + ]); + + // Permission modals: shown when the user asked to import but the browser + // hasn't granted permission yet. Once granted, the provider refreshes + // `topSites` / `bookmarks` and the effect above finishes the import. + if ( + showImportSource === 'topSites' && + hasCheckedTopSitesPermission && + topSites === undefined + ) { + const onGrant = async () => { + const granted = await askTopSitesPermission(); + if (!granted) { + setShowImportSource?.(null); + } + }; + return ( + setShowImportSource?.(null)} + > + + + Show most visited sites + + To import your most visited sites, your browser will ask for + permission. Once approved, the data is kept locally. + + + + We will never collect your browsing history. We promise. + + + + + + + ); + } + + if ( + showImportSource === 'bookmarks' && + hasCheckedBookmarksPermission && + bookmarks === undefined + ) { + const onGrant = async () => { + const granted = await askBookmarksPermission(); + if (!granted) { + setShowImportSource?.(null); + } + }; + return ( + setShowImportSource?.(null)} + > + + + Import your bookmarks bar + + To import your bookmarks bar, your browser will ask for permission + to read bookmarks. We never sync your bookmarks to our servers. + + + + + + + ); + } + + return null; +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index 4b857c7f4f0..48c3f3c4222 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -41,6 +41,13 @@ jest.mock('@dailydotdev/shared/src/lib/boot', () => ({ getBootData: jest.fn(), })); +// Pin these tests to the legacy code path. The shortcuts hub redesign is +// default-on in production; the suite below exercises the legacy UI that the +// hub is replacing behind the feature flag. +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + useConditionalFeature: () => ({ value: false, isLoading: false }), +})); + jest.mock('webextension-polyfill', () => { let providedPermission = false; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 228a69ac85f..1c63ef20879 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -13,14 +13,21 @@ import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { useShortcutLinks } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutLinks'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureShortcutsHub } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; import { ShortcutLinksList } from './ShortcutLinksList'; import { ShortcutGetStarted } from './ShortcutGetStarted'; +import { ShortcutLinksHub } from './ShortcutLinksHub'; +import { ShortcutImportFlow } from './ShortcutImportFlow'; interface ShortcutLinksProps { shouldUseListFeedLayout: boolean; } -export default function ShortcutLinks({ +function LegacyShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement { const { openModal } = useLazyModal(); @@ -123,3 +130,53 @@ export default function ShortcutLinks({ ); } + +function NewShortcutLinks({ + shouldUseListFeedLayout, +}: ShortcutLinksProps): ReactElement { + const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const manager = useShortcutsManager(); + const { openModal } = useLazyModal(); + const { setShowImportSource } = useShortcuts(); + useShortcutsMigration(); + + if (!showTopSites) { + return <>; + } + + if (manager.shortcuts.length === 0) { + return ( + <> + + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }) + } + onImportClick={() => setShowImportSource?.('topSites')} + /> + + + ); + } + + return ( + <> + + + + ); +} + +export default function ShortcutLinks(props: ShortcutLinksProps): ReactElement { + const { user } = useAuthContext(); + const { value: hubEnabled } = useConditionalFeature({ + feature: featureShortcutsHub, + shouldEvaluate: !!user, + }); + + if (user && hubEnabled) { + return ; + } + + return ; +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx new file mode 100644 index 00000000000..fa198384bb4 --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -0,0 +1,221 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; +import { + BookmarkIcon, + EyeIcon, + MenuIcon, + PlusIcon, + SettingsIcon, + SitesIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { MenuIcon as WrappingMenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import { ShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/ShortcutTile'; +import { AddShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/AddShortcutTile'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { 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 { + LogEvent, + ShortcutsSourceType, + TargetType, +} from '@dailydotdev/shared/src/lib/log'; +import type { Shortcut } from '@dailydotdev/shared/src/features/shortcuts/types'; + +interface ShortcutLinksHubProps { + shouldUseListFeedLayout: boolean; +} + +export function ShortcutLinksHub({ + shouldUseListFeedLayout, +}: ShortcutLinksHubProps): ReactElement { + const { openModal } = useLazyModal(); + const { toggleShowTopSites, showTopSites } = useSettingsContext(); + const { logEvent } = useLogContext(); + const manager = useShortcutsManager(); + const { setShowImportSource } = useShortcuts(); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const justDraggedRef = useRef(false); + const suppressClickCapture = (event: React.MouseEvent) => { + if (!justDraggedRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + justDraggedRef.current = false; + }; + + const loggedRef = useRef(false); + useEffect(() => { + if (loggedRef.current || !showTopSites) { + return; + } + loggedRef.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), + }); + }, [logEvent, showTopSites]); + + const [reorderAnnouncement, setReorderAnnouncement] = useState(''); + + const handleDragEnd = (event: DragEndEvent) => { + justDraggedRef.current = true; + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const urls = manager.shortcuts.map((s) => s.url); + const oldIndex = urls.indexOf(active.id as string); + const newIndex = urls.indexOf(over.id as string); + if (oldIndex < 0 || newIndex < 0) { + return; + } + manager.reorder(arrayMove(urls, oldIndex, newIndex)); + const moved = manager.shortcuts[oldIndex]; + const label = moved?.name || moved?.url || 'Shortcut'; + setReorderAnnouncement( + `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, + ); + }; + + const onLinkClick = () => + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), + }); + + const onEdit = (shortcut: Shortcut) => + openModal({ + type: LazyModal.ShortcutEdit, + props: { mode: 'edit', shortcut }, + }); + + const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + + const onAdd = () => + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + + const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); + + const menuOptions = [ + { + icon: , + label: 'Add shortcut', + action: onAdd, + }, + { + icon: , + label: 'Import from browser', + action: () => setShowImportSource?.('topSites'), + }, + { + icon: , + label: 'Import from bookmarks', + action: () => setShowImportSource?.('bookmarks'), + }, + { + icon: , + label: 'Hide', + action: toggleShowTopSites, + }, + { + icon: , + label: 'Manage', + action: onManage, + }, + ]; + + return ( +
+ + s.url)} + strategy={horizontalListSortingStrategy} + > + {manager.shortcuts.map((shortcut) => ( + + ))} + + + {manager.canAdd && } + + {reorderAnnouncement} + + + +
+ ); +} diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index c1a8ae6c6d5..48eeada183c 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -261,6 +261,34 @@ const CustomLinksModal = dynamic( ), ); +const ShortcutEditModal = dynamic( + () => + import( + /* webpackChunkName: "shortcutEditModal" */ '../../features/shortcuts/components/modals/ShortcutEditModal' + ), +); + +const ShortcutsManageModal = dynamic( + () => + import( + /* webpackChunkName: "shortcutsManageModal" */ '../../features/shortcuts/components/modals/ShortcutsManageModal' + ), +); + +const BookmarksPermissionModal = dynamic( + () => + import( + /* webpackChunkName: "bookmarksPermissionModal" */ '../../features/shortcuts/components/modals/BookmarksPermissionModal' + ), +); + +const ImportPickerModal = dynamic( + () => + import( + /* webpackChunkName: "importPickerModal" */ '../../features/shortcuts/components/modals/ImportPickerModal' + ), +); + const ListAwardsModal = dynamic(() => import( /* webpackChunkName: "listAwardsModal" */ './award/ListAwardsModal' @@ -499,6 +527,10 @@ export const modals = { [LazyModal.GiveAward]: GiveAwardModal, [LazyModal.ContentModal]: ContentModal, [LazyModal.CustomLinks]: CustomLinksModal, + [LazyModal.ShortcutEdit]: ShortcutEditModal, + [LazyModal.ShortcutsManage]: ShortcutsManageModal, + [LazyModal.BookmarksPermission]: BookmarksPermissionModal, + [LazyModal.ImportPicker]: ImportPickerModal, [LazyModal.ListAwards]: ListAwardsModal, [LazyModal.AdsDashboard]: AdsDashboardModal, [LazyModal.BoostPost]: BoostPostModal, diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index f331f4ff9d7..2699fcfdae3 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -68,6 +68,10 @@ export enum LazyModal { GiveAward = 'giveAward', ContentModal = 'contentModal', CustomLinks = 'customLinks', + ShortcutEdit = 'shortcutEdit', + ShortcutsManage = 'shortcutsManage', + BookmarksPermission = 'bookmarksPermission', + ImportPicker = 'importPicker', ListAwards = 'listAwards', AdsDashboard = 'adsDashboard', DirtyForm = 'dirtyForm', diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 79b0f9d5edf..f7e8afed68d 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -13,6 +13,7 @@ import type { SettingsFlags, Spaciness, } from '../graphql/settings'; +import type { ShortcutMeta } from '../features/shortcuts/types'; import { CampaignCtaPlacement, UPDATE_USER_SETTINGS_MUTATION, @@ -59,6 +60,11 @@ export interface SettingsContextData extends Omit { toggleShowFeedbackButton: () => Promise; loadedSettings: boolean; updateCustomLinks: (links: string[]) => Promise; + updateShortcutMeta: ( + url: string, + patch: ShortcutMeta | null, + ) => Promise; + removeShortcut: (url: string) => Promise; updateSortCommentsBy: (sort: SortCommentsBy) => Promise; updateFlag: ( flag: keyof SettingsFlags, @@ -293,6 +299,45 @@ export const SettingsContextProvider = ({ loadedSettings: loadedSettings ?? false, updateCustomLinks: (links: string[]) => setSettings({ ...settings, customLinks: links }), + updateShortcutMeta: (url: string, patch: ShortcutMeta | null) => { + const current = settings.flags?.shortcutMeta ?? {}; + const next = { ...current }; + if (!patch) { + delete next[url]; + } else { + const merged = { ...(current[url] ?? {}), ...patch }; + const isEmpty = + !merged.name && !merged.iconUrl && !merged.color; + if (isEmpty) { + delete next[url]; + } else { + next[url] = merged; + } + } + return setSettings({ + ...settings, + flags: { + ...settings.flags, + shortcutMeta: next, + } as SettingsFlags, + }); + }, + removeShortcut: (url: string) => { + const nextLinks = (settings.customLinks ?? []).filter( + (existing) => existing !== url, + ); + const current = settings.flags?.shortcutMeta ?? {}; + const nextMeta = { ...current }; + delete nextMeta[url]; + return setSettings({ + ...settings, + customLinks: nextLinks, + flags: { + ...settings.flags, + shortcutMeta: nextMeta, + } as SettingsFlags, + }); + }, updateSortCommentsBy: (sortCommentsBy: SortCommentsBy) => setSettings({ ...settings, sortCommentsBy }), updateFlag: (flag: keyof SettingsFlags, value: string | boolean) => diff --git a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx new file mode 100644 index 00000000000..798efe36c4d --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { PlusIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; + +interface AddShortcutTileProps { + onClick: () => void; + disabled?: boolean; +} + +export function AddShortcutTile({ + onClick, + disabled, +}: AddShortcutTileProps): ReactElement { + return ( + + ); +} 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..a2f6c2e3680 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -0,0 +1,246 @@ +import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../../../components/dropdown/DropdownMenu'; +import { EditIcon, MenuIcon, TrashIcon } from '../../../components/icons'; +import { MenuIcon as WrappingMenuIcon } from '../../../components/MenuIcon'; +import { IconSize } from '../../../components/Icon'; +import { combinedClicks } from '../../../lib/click'; +import { apiUrl } from '../../../lib/config'; +import { getDomainFromUrl } from '../../../lib/links'; +import type { Shortcut, ShortcutColor } from '../types'; + +const pixelRatio = + typeof globalThis?.window === 'undefined' + ? 1 + : globalThis.window.devicePixelRatio ?? 1; +const iconSize = Math.round(24 * pixelRatio); + +const colorClass: Record = { + burger: 'bg-accent-burger-bolder text-white', + cheese: 'bg-accent-cheese-bolder text-black', + avocado: 'bg-accent-avocado-bolder text-white', + bacon: 'bg-accent-bacon-bolder text-white', + blueCheese: 'bg-accent-blueCheese-bolder text-white', + cabbage: 'bg-accent-cabbage-bolder text-white', +}; + +const colorGlowClass: Record = { + burger: 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-burger-default)/0.45)]', + cheese: + 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-cheese-default)/0.45)]', + avocado: + 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-avocado-default)/0.45)]', + bacon: + 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-bacon-default)/0.45)]', + blueCheese: + 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-blueCheese-default)/0.45)]', + cabbage: + 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-cabbage-default)/0.45)]', +}; + +interface LetterChipProps { + name: string; + color: ShortcutColor; + size?: 'sm' | 'lg'; +} + +function LetterChip({ + name, + color, + size = 'sm', +}: LetterChipProps): ReactElement { + const letter = (name || '?').charAt(0).toUpperCase(); + return ( + + {letter} + + ); +} + +interface ShortcutTileProps { + shortcut: Shortcut; + draggable?: boolean; + onClick?: () => void; + onEdit?: (shortcut: Shortcut) => void; + onRemove?: (shortcut: Shortcut) => void; + className?: string; +} + +export function ShortcutTile({ + shortcut, + draggable = true, + onClick, + onEdit, + onRemove, + className, +}: ShortcutTileProps): ReactElement { + const { url, name, iconUrl, color = 'burger' } = 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 handleAnchorClick = useCallback( + (event: MouseEvent) => { + if (isDragging) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onClick?.(); + }, + [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 menuOptions = [ + ...(onEdit + ? [ + { + icon: , + label: 'Edit', + action: () => onEdit(shortcut), + }, + ] + : []), + ...(onRemove + ? [ + { + icon: , + label: 'Remove', + action: () => onRemove(shortcut), + }, + ] + : []), + ]; + + return ( +
+ + {shouldShowFavicon ? ( + {label} + ) : ( + + )} + + + {label} + + + {draggable && ( + + )} + + {menuOptions.length > 0 && ( + + +
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx b/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx new file mode 100644 index 00000000000..505eb67ffef --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +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 { useShortcuts } from '../../contexts/ShortcutsProvider'; +import { useShortcutsManager } from '../../hooks/useShortcutsManager'; + +export default function BookmarksPermissionModal({ + ...props +}: ModalProps): ReactElement { + const { askBookmarksPermission, bookmarks, setShowImportSource } = + useShortcuts(); + const manager = useShortcutsManager({ bookmarks }); + + const handleGrant = async () => { + const granted = await askBookmarksPermission(); + if (!granted) { + return; + } + // After permission granted we can't always trust `bookmarks` is populated + // synchronously. Delay one tick and import whatever we have. + setTimeout(async () => { + await manager.importFrom( + 'bookmarks', + (bookmarks ?? []).map((b) => ({ url: b.url, title: b.title })), + ); + setShowImportSource?.(null); + props.onRequestClose?.(undefined as never); + }, 0); + }; + + const onRequestClose = () => { + setShowImportSource?.(null); + props.onRequestClose?.(undefined as never); + }; + + 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. + + + + + + + ); +} 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..9d219a24cbf --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -0,0 +1,167 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonVariant } from '../../../../components/buttons/Button'; +import { Checkbox } from '../../../../components/fields/Checkbox'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { Justify } from '../../../../components/utilities'; +import { apiUrl } from '../../../../lib/config'; +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'; + +export interface ImportPickerItem { + url: string; + title?: string; +} + +export interface ImportPickerModalProps extends ModalProps { + source: ImportSource; + items: ImportPickerItem[]; + onImported?: (result: { imported: number; skipped: number }) => void; +} + +export default function ImportPickerModal({ + source, + items, + onImported, + ...props +}: ImportPickerModalProps): ReactElement { + const { customLinks } = useSettingsContext(); + const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); + + const capacity = Math.max( + 0, + MAX_SHORTCUTS - (customLinks?.length ?? 0), + ); + const [checked, setChecked] = useState>(() => { + const state: Record = {}; + items.slice(0, capacity).forEach((item) => { + state[item.url] = true; + }); + return state; + }); + + const selected = useMemo( + () => items.filter((item) => checked[item.url]), + [checked, items], + ); + + const toggle = (url: string, next: boolean) => + setChecked((prev) => ({ ...prev, [url]: next })); + + const handleImport = async () => { + const result = await manager.importFrom(source, selected); + onImported?.(result); + displayToast( + `Imported ${result.imported} ${ + source === 'bookmarks' ? 'bookmarks' : 'sites' + } to shortcuts${result.skipped ? `. ${result.skipped} skipped.` : ''}`, + ); + props.onRequestClose?.(undefined as never); + }; + + const selectableCount = Math.min(items.length, capacity); + const allSelected = + selectableCount > 0 && selected.length >= selectableCount; + const toggleAll = () => { + if (allSelected) { + setChecked({}); + return; + } + const next: Record = {}; + items.slice(0, capacity).forEach((item) => { + next[item.url] = true; + }); + setChecked(next); + }; + + return ( + + + + {source === 'bookmarks' + ? 'Pick bookmarks to import' + : 'Pick sites to import'} + + + +
+

+ + {selected.length} + {' '} + of {capacity} slots selected +

+ +
+
    + {items.map((item) => { + const isChecked = !!checked[item.url]; + const atCap = !isChecked && selected.length >= capacity; + return ( +
  • + toggle(item.url, next)} + /> + +
    +

    + {item.title || item.url} +

    +

    + {item.url} +

    +
    +
  • + ); + })} +
+
+ + + + +
+ ); +} 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..563c8ac8c71 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -0,0 +1,221 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import ControlledTextField from '../../../../components/fields/ControlledTextField'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { Justify } from '../../../../components/utilities'; +import { useShortcutsManager } from '../../hooks/useShortcutsManager'; +import { ShortcutTile } from '../ShortcutTile'; +import type { Shortcut, ShortcutColor } from '../../types'; +import { shortcutColorPalette } from '../../types'; +import { isValidHttpUrl, withHttps } from '../../../../lib/links'; +import classNames from 'classnames'; + +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', + ), + color: z.string().optional(), +}); + +type FormValues = z.infer; + +type ShortcutEditModalProps = ModalProps & { + mode: 'add' | 'edit'; + shortcut?: Shortcut; + onSubmitted?: () => void; +}; + +const colorSwatchClass: Record = { + burger: 'bg-accent-burger-bolder', + cheese: 'bg-accent-cheese-bolder', + avocado: 'bg-accent-avocado-bolder', + bacon: 'bg-accent-bacon-bolder', + blueCheese: 'bg-accent-blueCheese-bolder', + cabbage: 'bg-accent-cabbage-bolder', +}; + +const colorLabel: Record = { + burger: 'Burger', + cheese: 'Cheese', + avocado: 'Avocado', + bacon: 'Bacon', + blueCheese: 'Blue cheese', + cabbage: 'Cabbage', +}; + +export default function ShortcutEditModal({ + mode, + shortcut, + onSubmitted, + ...props +}: ShortcutEditModalProps): ReactElement { + const manager = useShortcutsManager(); + const methods = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: shortcut?.name ?? '', + url: shortcut?.url ?? '', + iconUrl: shortcut?.iconUrl ?? '', + color: shortcut?.color ?? '', + }, + mode: 'onBlur', + }); + + const { + handleSubmit, + watch, + setError, + formState: { isSubmitting }, + } = methods; + + const values = watch(); + const previewShortcut = useMemo( + () => ({ + url: values.url || 'https://example.com', + name: values.name || undefined, + iconUrl: values.iconUrl || undefined, + color: (values.color as ShortcutColor) || 'burger', + }), + [values.color, values.iconUrl, values.name, values.url], + ); + + const onSubmit = handleSubmit(async (data) => { + const payload = { + url: data.url, + name: data.name || undefined, + iconUrl: data.iconUrl || undefined, + color: data.color || undefined, + }; + + const result = + mode === 'add' + ? await manager.addShortcut(payload) + : await manager.updateShortcut(shortcut!.url, payload); + + if (result.error) { + setError('url', { message: result.error }); + return; + } + + onSubmitted?.(); + props.onRequestClose?.(undefined as never); + }); + + return ( + + + + {mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} + + + + +
+
+
+
+ +
+ +
+ + + +
+ + Accent color (used when no favicon is available) + +
+ {shortcutColorPalette.map((color: ShortcutColor) => { + const checked = values.color === color; + return ( +
+
+
+ + + + + + + + + ); +} 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..fdc88ac6932 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -0,0 +1,403 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } 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 { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { HorizontalSeparator } from '../../../../components/utilities'; +import { Switch } from '../../../../components/fields/Switch'; +import { + BookmarkIcon, + DownloadIcon, + EditIcon, + MenuIcon, + PlusIcon, + SitesIcon, + TrashIcon, +} from '../../../../components/icons'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../../../../components/dropdown/DropdownMenu'; +import { MenuIcon as WrappingMenuIcon } from '../../../../components/MenuIcon'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../../lib/log'; +import { useShortcutsManager } from '../../hooks/useShortcutsManager'; +import { useShortcuts } from '../../contexts/ShortcutsProvider'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { apiUrl } from '../../../../lib/config'; +import { getDomainFromUrl } from '../../../../lib/links'; +import { MAX_SHORTCUTS } from '../../types'; +import type { Shortcut } from '../../types'; + +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 style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const label = shortcut.name || getDomainFromUrl(shortcut.url); + + return ( +
+ + +
+

{label}

+

+ {shortcut.url} +

+
+
+ ); +} + +export default function ShortcutsManageModal( + props: ModalProps, +): ReactElement { + const { logEvent } = useLogContext(); + const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const manager = useShortcutsManager(); + const { + onRevokePermission, + setShowImportSource, + hasCheckedPermission, + hasCheckedBookmarksPermission, + bookmarks, + revokeBookmarksPermission, + } = useShortcuts(); + const hasBookmarksPermission = + hasCheckedBookmarksPermission && bookmarks !== undefined; + const { openModal } = useLazyModal(); + + const logRef = useRef(); + logRef.current = logEvent; + + useEffect(() => { + logRef.current?.({ + event_name: LogEvent.OpenShortcutConfig, + target_type: TargetType.Shortcuts, + }); + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const urls = manager.shortcuts.map((s) => s.url); + const oldIndex = urls.indexOf(active.id as string); + const newIndex = urls.indexOf(over.id as string); + manager.reorder(arrayMove(urls, oldIndex, newIndex)); + }; + + const onEdit = (shortcut: Shortcut) => { + openModal({ + type: LazyModal.ShortcutEdit, + props: { mode: 'edit', shortcut }, + }); + }; + + const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + + const onAdd = () => + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + + const importOptions = [ + { + icon: , + label: 'From most visited', + action: () => setShowImportSource?.('topSites'), + }, + { + icon: , + label: 'From bookmarks bar', + action: () => setShowImportSource?.('bookmarks'), + }, + ]; + + return ( + + +
+ + Shortcuts + + + {manager.shortcuts.length}/{MAX_SHORTCUTS} + +
+
+ + + + + + + + + + +
+
+ +
+
+
+ + Show shortcuts + + + Toggle the shortcut row visibility on the new-tab page. + +
+ + {showTopSites ? 'On' : 'Off'} + +
+ + + + {manager.shortcuts.length === 0 ? ( +
+ + + +
+ + No shortcuts yet + + + Add your first shortcut or import from your browser. + +
+
+ + + +
+
+ ) : ( + + s.url)} + strategy={verticalListSortingStrategy} + > +
+ {manager.shortcuts.map((shortcut) => ( + + ))} +
+
+
+ )} + + {(hasCheckedPermission || hasBookmarksPermission) && ( +
+ {hasCheckedPermission && ( + + )} + {hasBookmarksPermission && revokeBookmarksPermission && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx b/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx index 933dec44bfd..78aaada133f 100644 --- a/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx +++ b/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx @@ -1,9 +1,11 @@ import { 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'; const [ShortcutsProvider, useShortcuts] = createContextProvider( () => { @@ -12,6 +14,9 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( const [isManual, setIsManual] = useState(false); const [showPermissionsModal, setShowPermissionsModal] = useState(false); + const [showImportSource, setShowImportSource] = useState< + ImportSource | null + >(null); const { topSites, @@ -20,6 +25,13 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( revokePermission, } = useTopSites(); + const { + bookmarks, + hasCheckedPermission: hasCheckedBookmarksPermission, + askBookmarksPermission, + revokeBookmarksPermission, + } = useBrowserBookmarks(); + const onRevokePermission = async () => { await revokePermission(); @@ -52,6 +64,13 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( onRevokePermission, showPermissionsModal, setShowPermissionsModal, + // New hub state + bookmarks, + hasCheckedBookmarksPermission, + askBookmarksPermission, + revokeBookmarksPermission, + showImportSource, + setShowImportSource, }; }, { 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..647df3b473a --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useBrowserBookmarks.ts @@ -0,0 +1,164 @@ +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; + } + for (const node of nodes) { + if (isBookmarksBarNode(node)) { + return node; + } + const nested = findBookmarksBar(node.children); + if (nested) { + return nested; + } + } + return null; +}; + +type FlattenResult = { bookmarks: BrowserBookmark[]; skippedNested: number }; + +const flattenBar = (bar: Bookmarks.BookmarkTreeNode): FlattenResult => { + const bookmarks: BrowserBookmark[] = []; + let skippedNested = 0; + + const walk = (nodes: Bookmarks.BookmarkTreeNode[], depth: number) => { + for (const node of nodes) { + if (node.url) { + bookmarks.push({ + title: node.title || node.url, + url: node.url, + }); + // eslint-disable-next-line no-continue + continue; + } + // Folder + if (depth === 0) { + // Flatten one level deep only. + if (node.children?.length) { + walk(node.children, depth + 1); + } + } else if (node.children) { + skippedNested += node.children.filter((c) => c.url).length; + } + } + }; + + walk(bar.children ?? [], 0); + return { bookmarks, skippedNested }; +}; + +export interface UseBrowserBookmarks { + bookmarks: BrowserBookmark[] | undefined; + skippedNested: number; + hasCheckedPermission: boolean; + askBookmarksPermission: () => Promise; + revokeBookmarksPermission: () => Promise; +} + +export const useBrowserBookmarks = (): UseBrowserBookmarks => { + const [browser, setBrowser] = useState(); + const [bookmarks, setBookmarks] = useState(); + const [skippedNested, setSkippedNested] = useState(0); + const [hasCheckedPermission, setHasCheckedPermission] = useState(false); + + const getBookmarks = useCallback(async (): Promise => { + if (!browser?.bookmarks) { + setBookmarks(undefined); + setSkippedNested(0); + setHasCheckedPermission(true); + return; + } + + try { + const tree = await browser.bookmarks.getTree(); + const bar = findBookmarksBar(tree); + if (!bar) { + setBookmarks([]); + setSkippedNested(0); + } else { + const { bookmarks: flat, skippedNested: skipped } = flattenBar(bar); + setBookmarks(flat); + setSkippedNested(skipped); + } + } catch (_) { + setBookmarks(undefined); + setSkippedNested(0); + } + + 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); + setSkippedNested(0); + }, [browser]); + + useEffect(() => { + if (!checkIsExtension()) { + return; + } + if (!browser) { + import('webextension-polyfill').then((mod) => setBrowser(mod.default)); + } else { + getBookmarks(); + } + }, [browser, getBookmarks]); + + return useMemo( + () => ({ + bookmarks, + skippedNested, + hasCheckedPermission, + askBookmarksPermission, + revokeBookmarksPermission, + }), + [ + bookmarks, + skippedNested, + hasCheckedPermission, + askBookmarksPermission, + revokeBookmarksPermission, + ], + ); +}; 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..65c189bc361 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts @@ -0,0 +1,339 @@ +import { useCallback, 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 { canonicalShortcutUrl, withHttps } from '../../../lib/links'; +import type { SettingsFlags } from '../../../graphql/settings'; +import type { + ImportSource, + Shortcut, + ShortcutMeta, +} from '../types'; +import { MAX_SHORTCUTS, UNDO_TIMEOUT_MS, shortcutColorPalette } from '../types'; +import { useShortcuts } from '../contexts/ShortcutsProvider'; +import type { BrowserBookmark } from './useBrowserBookmarks'; + +const hashString = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i += 1) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +}; + +const defaultColorForUrl = (url: string) => { + try { + const host = new URL(withHttps(url)).hostname; + return shortcutColorPalette[ + hashString(host) % shortcutColorPalette.length + ]; + } catch (_) { + return shortcutColorPalette[0]; + } +}; + +export interface UseShortcutsManager { + shortcuts: Shortcut[]; + canAdd: boolean; + addShortcut: (input: { + url: string; + name?: string; + iconUrl?: string; + color?: string; + }) => Promise<{ error?: string }>; + updateShortcut: ( + url: string, + patch: { url?: string; name?: string; iconUrl?: string; color?: 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; +} + +interface UseShortcutsManagerProps { + topSitesUrls?: string[]; + bookmarks?: BrowserBookmark[]; +} + +const colorIsValid = (color?: string): color is ShortcutMeta['color'] => + !!color && + (shortcutColorPalette as readonly string[]).includes(color); + +export const useShortcutsManager = ( + { topSitesUrls, bookmarks }: UseShortcutsManagerProps = {}, +): UseShortcutsManager => { + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const { customLinks, flags, updateCustomLinks, setSettings } = + useSettingsContext(); + const { setShowImportSource } = useShortcuts(); + + const metaMap = flags?.shortcutMeta ?? {}; + const links = useMemo(() => customLinks ?? [], [customLinks]); + + const shortcuts = useMemo( + () => + links.map((url) => { + const meta = metaMap[url] ?? {}; + return { + url, + name: meta.name, + iconUrl: meta.iconUrl, + color: meta.color ?? defaultColorForUrl(url), + }; + }), + [links, metaMap], + ); + + const canonicalMap = useMemo(() => { + const map = new Map(); + links.forEach((url) => { + const key = canonicalShortcutUrl(url); + if (key) { + map.set(key, url); + } + }); + return map; + }, [links]); + + const findDuplicate = useCallback( + (url: string) => { + const key = canonicalShortcutUrl(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( + ( + nextLinks: string[], + nextMeta: Record, + ): Promise => + setSettings({ + customLinks: nextLinks, + flags: { ...flags, shortcutMeta: nextMeta } as SettingsFlags, + }) as Promise, + [flags, setSettings], + ); + + const addShortcut: UseShortcutsManager['addShortcut'] = useCallback( + async ({ url, name, iconUrl, color }) => { + 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; + } + if (colorIsValid(color)) { + meta.color = color; + } + 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 } + : {}), + ...(patch.color !== undefined && colorIsValid(patch.color) + ? { color: patch.color } + : {}), + }; + + const nextMeta = { ...metaMap }; + delete nextMeta[url]; + const isEmpty = + !mergedMeta.name && !mergedMeta.iconUrl && !mergedMeta.color; + if (!isEmpty) { + nextMeta[nextUrl] = mergedMeta; + } + + await writeBatch(nextLinks, nextMeta); + log(LogEvent.EditShortcut); + return {}; + }, + [links, metaMap, findDuplicate, writeBatch, log], + ); + + const undoRef = useRef<{ timeout?: ReturnType }>({}); + + 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); + + if (undoRef.current.timeout) { + clearTimeout(undoRef.current.timeout); + } + + displayToast('Shortcut removed', { + timer: UNDO_TIMEOUT_MS, + action: { + copy: 'Undo', + onClick: async () => { + const restoredLinks = [...nextLinks]; + restoredLinks.splice(index, 0, url); + const restoredMeta = { ...nextMeta }; + 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; + + for (const item of items) { + if (batchLinks.length >= capacity) { + skipped += 1; + // eslint-disable-next-line no-continue + continue; + } + const httpsUrl = withHttps(item.url); + const key = canonicalShortcutUrl(httpsUrl); + if (!key || existingKeys.has(key)) { + skipped += 1; + // eslint-disable-next-line no-continue + continue; + } + 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..65f486a4807 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts @@ -0,0 +1,61 @@ +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. + */ +export const useShortcutsMigration = (): void => { + const { customLinks } = useSettingsContext(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const { topSites, hasCheckedPermission } = useShortcuts(); + const manager = useShortcutsManager({ + topSitesUrls: topSites?.map((s) => s.url), + }); + const { displayToast } = useToastNotification(); + const ranRef = useRef(false); + + useEffect(() => { + if (ranRef.current) { + return; + } + if (!isActionsFetched || !hasCheckedPermission) { + return; + } + if (checkHasCompleted(ActionType.ShortcutsMigratedFromTopSites)) { + return; + } + if ((customLinks?.length ?? 0) > 0) { + return; + } + if (!topSites?.length) { + return; + } + + ranRef.current = true; + const items = topSites.map((s) => ({ url: s.url })); + manager.importFrom('topSites', items).then((result) => { + if (result.imported > 0) { + displayToast( + 'We imported your most visited sites. You can edit them anytime.', + ); + } + completeAction(ActionType.ShortcutsMigratedFromTopSites); + }); + }, [ + isActionsFetched, + hasCheckedPermission, + checkHasCompleted, + completeAction, + customLinks, + topSites, + manager, + displayToast, + ]); +}; diff --git a/packages/shared/src/features/shortcuts/types.ts b/packages/shared/src/features/shortcuts/types.ts new file mode 100644 index 00000000000..01760576d07 --- /dev/null +++ b/packages/shared/src/features/shortcuts/types.ts @@ -0,0 +1,34 @@ +export type ShortcutColor = + | 'burger' + | 'cheese' + | 'avocado' + | 'bacon' + | 'blueCheese' + | 'cabbage'; + +export const shortcutColorPalette: readonly ShortcutColor[] = [ + 'burger', + 'cheese', + 'avocado', + 'bacon', + 'blueCheese', + 'cabbage', +] as const; + +export type ShortcutMeta = { + name?: string; + iconUrl?: string; + color?: ShortcutColor; +}; + +export type Shortcut = { + url: string; + name?: string; + iconUrl?: string; + color?: ShortcutColor; +}; + +export type ImportSource = 'topSites' | 'bookmarks'; + +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 6bf277620a8..85d648dfa29 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..7212f1d14b1 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -1,6 +1,7 @@ import { gql } from 'graphql-request'; import type { SortCommentsBy } from './comments'; import type { WriteFormTab } from '../components/fields/form/common'; +import type { ShortcutMeta } from '../features/shortcuts/types'; export type Spaciness = 'eco' | 'roomy' | 'cozy'; export type RemoteTheme = 'darcula' | 'bright' | 'auto'; @@ -20,6 +21,7 @@ export type SettingsFlags = { timezoneMismatchIgnore?: string; prompt?: Record; defaultWriteTab?: WriteFormTab; + shortcutMeta?: Record; }; export enum SidebarSettingsFlags { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 08cb456228b..d80efa9db03 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', true); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index ad068d188ce..122cc3ba948 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -29,6 +29,29 @@ export const stripLinkParameters = (link: string): string => { return origin + pathname; }; +/** + * Canonical URL form used for duplicate detection across shortcuts. + * origin + pathname, lowercased, trailing slash stripped. + */ +export const canonicalShortcutUrl = (link: string): string | null => { + try { + const url = new URL(withHttps(link)); + const origin = url.origin.toLowerCase(); + const pathname = url.pathname.replace(/\/+$/, ''); + return `${origin}${pathname}`; + } catch (_) { + return null; + } +}; + +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 eb0a91c9b6a..b20fd904ca3 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -200,6 +200,12 @@ 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', // Devcard ShareDevcard = 'share devcard', GenerateDevcard = 'generate devcard', @@ -618,6 +624,7 @@ export enum ShortcutsSourceType { Browser = 'browser', Placeholder = 'placeholder', Button = 'button', + Bookmarks = 'bookmarks', } export enum UserAcquisitionEvent { From 96aaf06b79bb67fc925b40d096f9f434ad62ec32 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:24:29 +0300 Subject: [PATCH 02/32] feat(shortcuts): allow uploading a custom icon image Adds an ImageInput uploader to the shortcut edit modal so users can pick or drag & drop an image file instead of hunting for an icon URL. The file is uploaded via uploadContentImage and the returned CDN URL is stored in shortcutMeta.iconUrl, keeping the persistence model unchanged. The URL input is kept as a secondary fallback ("Or paste an image URL instead") for power users. Save is disabled while an upload is in flight, and the live tile preview shows the base64 preview immediately for instant feedback. Made-with: Cursor --- .../components/modals/ShortcutEditModal.tsx | 109 ++++++++++++++++-- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 563c8ac8c71..638d7bda45d 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -1,13 +1,15 @@ import type { ReactElement } from 'react'; -import React, { useMemo } from 'react'; +import React, { useMemo, 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 { Button, ButtonVariant, } from '../../../../components/buttons/Button'; import ControlledTextField from '../../../../components/fields/ControlledTextField'; +import ImageInput from '../../../../components/fields/ImageInput'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; import { Justify } from '../../../../components/utilities'; @@ -16,7 +18,12 @@ import { ShortcutTile } from '../ShortcutTile'; import type { Shortcut, ShortcutColor } from '../../types'; import { shortcutColorPalette } from '../../types'; import { isValidHttpUrl, withHttps } from '../../../../lib/links'; -import classNames from 'classnames'; +import { CameraIcon } from '../../../../components/icons'; +import { + imageSizeLimitMB, + uploadContentImage, +} from '../../../../graphql/posts'; +import { useToastNotification } from '../../../../hooks/useToastNotification'; const schema = z.object({ name: z @@ -35,7 +42,10 @@ const schema = z.object({ .string() .optional() .refine( - (value) => !value || isValidHttpUrl(withHttps(value)), + (value) => + !value || + value.startsWith('data:image/') || + isValidHttpUrl(withHttps(value)), 'Must be a valid URL', ), color: z.string().optional(), @@ -74,6 +84,9 @@ export default function ShortcutEditModal({ ...props }: ShortcutEditModalProps): ReactElement { const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); + const [isUploading, setIsUploading] = useState(false); + const [showUrlInput, setShowUrlInput] = useState(false); const methods = useForm({ resolver: zodResolver(schema), defaultValues: { @@ -89,9 +102,35 @@ export default function ShortcutEditModal({ handleSubmit, watch, setError, + clearErrors, + setValue, formState: { isSubmitting }, } = methods; + const handleIconUpload = async (base64: string | null, file?: File) => { + if (!file || !base64) { + clearErrors('iconUrl'); + setValue('iconUrl', '', { shouldDirty: true }); + return; + } + clearErrors('iconUrl'); + // Show the base64 preview immediately while the upload finishes. + 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 values = watch(); const previewShortcut = useMemo( () => ({ @@ -153,12 +192,62 @@ export default function ShortcutEditModal({ label="URL" placeholder="https://example.com" /> - +
+ + Custom icon (optional) + +
+ } + className={{ + container: classNames( + 'rounded-14 border-border-subtlest-tertiary bg-surface-float', + isUploading && 'opacity-60', + ), + }} + > + + +
+ + Upload an image or drag & drop. Leave empty to use the + site favicon. + + {isUploading && ( + + Uploading… + + )} +
+
+ + {showUrlInput && ( +
+ +
+ )} +
Accent color (used when no favicon is available) @@ -211,7 +300,7 @@ export default function ShortcutEditModal({ type="submit" form="shortcut-edit-form" variant={ButtonVariant.Primary} - disabled={isSubmitting} + disabled={isSubmitting || isUploading} > {mode === 'add' ? 'Add' : 'Save'} From 9550fca19bb395c04db0147fdaee7f6d98ac7681 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:28:10 +0300 Subject: [PATCH 03/32] refactor(shortcuts): remove accent color picker from edit modal The color was only visible in two edge cases (hover glow + letter chip fallback when no favicon loads), which rarely surfaced in practice. The picker added cognitive load to the edit form for negligible UX benefit. Tiles still receive a deterministic color derived from the URL in ShortcutTile, so the letter-chip fallback keeps its polish. Legacy shortcutMeta.color values continue to be respected. Made-with: Cursor --- .../components/modals/ShortcutEditModal.tsx | 65 ++----------------- 1 file changed, 4 insertions(+), 61 deletions(-) diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 638d7bda45d..207e85bc300 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -15,8 +15,7 @@ import { Modal } from '../../../../components/modals/common/Modal'; import { Justify } from '../../../../components/utilities'; import { useShortcutsManager } from '../../hooks/useShortcutsManager'; import { ShortcutTile } from '../ShortcutTile'; -import type { Shortcut, ShortcutColor } from '../../types'; -import { shortcutColorPalette } from '../../types'; +import type { Shortcut } from '../../types'; import { isValidHttpUrl, withHttps } from '../../../../lib/links'; import { CameraIcon } from '../../../../components/icons'; import { @@ -48,7 +47,6 @@ const schema = z.object({ isValidHttpUrl(withHttps(value)), 'Must be a valid URL', ), - color: z.string().optional(), }); type FormValues = z.infer; @@ -59,24 +57,6 @@ type ShortcutEditModalProps = ModalProps & { onSubmitted?: () => void; }; -const colorSwatchClass: Record = { - burger: 'bg-accent-burger-bolder', - cheese: 'bg-accent-cheese-bolder', - avocado: 'bg-accent-avocado-bolder', - bacon: 'bg-accent-bacon-bolder', - blueCheese: 'bg-accent-blueCheese-bolder', - cabbage: 'bg-accent-cabbage-bolder', -}; - -const colorLabel: Record = { - burger: 'Burger', - cheese: 'Cheese', - avocado: 'Avocado', - bacon: 'Bacon', - blueCheese: 'Blue cheese', - cabbage: 'Cabbage', -}; - export default function ShortcutEditModal({ mode, shortcut, @@ -93,7 +73,6 @@ export default function ShortcutEditModal({ name: shortcut?.name ?? '', url: shortcut?.url ?? '', iconUrl: shortcut?.iconUrl ?? '', - color: shortcut?.color ?? '', }, mode: 'onBlur', }); @@ -137,9 +116,10 @@ export default function ShortcutEditModal({ url: values.url || 'https://example.com', name: values.name || undefined, iconUrl: values.iconUrl || undefined, - color: (values.color as ShortcutColor) || 'burger', + // Fallback color is derived from the URL in ShortcutTile when omitted, + // so the preview still looks right without a user-selected color. }), - [values.color, values.iconUrl, values.name, values.url], + [values.iconUrl, values.name, values.url], ); const onSubmit = handleSubmit(async (data) => { @@ -147,7 +127,6 @@ export default function ShortcutEditModal({ url: data.url, name: data.name || undefined, iconUrl: data.iconUrl || undefined, - color: data.color || undefined, }; const result = @@ -248,42 +227,6 @@ export default function ShortcutEditModal({
)}
-
- - Accent color (used when no favicon is available) - -
- {shortcutColorPalette.map((color: ShortcutColor) => { - const checked = values.color === color; - return ( -
-
From 9d1116e116aab9f3524bac6d846c4592156fb305 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:30:20 +0300 Subject: [PATCH 04/32] feat(shortcuts): defensively cap hub to MAX_SHORTCUTS tiles Previously the hub rendered every entry in customLinks, so legacy data, cross-device sync, or a direct settings mutation holding > 12 links would spill tiles across multiple rows and push the feed down. Mirror Chrome's fixed-cap behaviour: slice the rendered list to MAX_SHORTCUTS, append overflow during reorder so we never drop links silently, and surface a "+N more" affordance that opens the Manage modal where the user can see and remove the excess. Made-with: Cursor --- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index fa198384bb4..880c71bb077 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -50,6 +50,7 @@ import { TargetType, } from '@dailydotdev/shared/src/lib/log'; import type { Shortcut } from '@dailydotdev/shared/src/features/shortcuts/types'; +import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; interface ShortcutLinksHubProps { shouldUseListFeedLayout: boolean; @@ -98,20 +99,32 @@ export function ShortcutLinksHub({ const [reorderAnnouncement, setReorderAnnouncement] = useState(''); + // Defensive cap: never render more than MAX_SHORTCUTS tiles on the new tab + // even if `customLinks` somehow contains more (legacy data, cross-device + // sync, direct settings mutation). Overflow stays visible + removable in + // the Manage modal. Mirrors Chrome's new-tab behaviour of a fixed cap. + const visibleShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); + const overflowCount = manager.shortcuts.length - visibleShortcuts.length; + const handleDragEnd = (event: DragEndEvent) => { justDraggedRef.current = true; const { active, over } = event; if (!over || active.id === over.id) { return; } - const urls = manager.shortcuts.map((s) => s.url); + const urls = visibleShortcuts.map((s) => s.url); const oldIndex = urls.indexOf(active.id as string); const newIndex = urls.indexOf(over.id as string); if (oldIndex < 0 || newIndex < 0) { return; } - manager.reorder(arrayMove(urls, oldIndex, newIndex)); - const moved = manager.shortcuts[oldIndex]; + // Reorder affects only the visible window; append any overflow so we + // don't silently drop them from customLinks. + const overflowUrls = manager.shortcuts + .slice(MAX_SHORTCUTS) + .map((s) => s.url); + manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); + const moved = visibleShortcuts[oldIndex]; const label = moved?.name || moved?.url || 'Shortcut'; setReorderAnnouncement( `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, @@ -183,10 +196,10 @@ export function ShortcutLinksHub({ onDragEnd={handleDragEnd} > s.url)} + items={visibleShortcuts.map((s) => s.url)} strategy={horizontalListSortingStrategy} > - {manager.shortcuts.map((shortcut) => ( + {visibleShortcuts.map((shortcut) => ( {manager.canAdd && } + {overflowCount > 0 && ( + + )} {reorderAnnouncement} From 5f817ee1e081d16c370cf5b23c259dd6de29931d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:34:18 +0300 Subject: [PATCH 05/32] feat(icons): add DragIcon and use it for shortcut drag handles Replaces the rotated hamburger affordance on shortcut tiles and manage- modal rows with a proper 6-dot grip icon, matching the daily.dev Figma design system ("Shapes/"). The new icon follows the standard filled/outlined pattern used by every other icon in the library. Made-with: Cursor --- packages/shared/src/components/icons/Drag/filled.svg | 8 ++++++++ packages/shared/src/components/icons/Drag/index.tsx | 10 ++++++++++ packages/shared/src/components/icons/Drag/outlined.svg | 8 ++++++++ packages/shared/src/components/icons/index.ts | 1 + .../src/features/shortcuts/components/ShortcutTile.tsx | 9 +++++++-- .../components/modals/ShortcutsManageModal.tsx | 4 ++-- 6 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/components/icons/Drag/filled.svg create mode 100644 packages/shared/src/components/icons/Drag/index.tsx create mode 100644 packages/shared/src/components/icons/Drag/outlined.svg diff --git a/packages/shared/src/components/icons/Drag/filled.svg b/packages/shared/src/components/icons/Drag/filled.svg new file mode 100644 index 00000000000..c974c45f128 --- /dev/null +++ b/packages/shared/src/components/icons/Drag/filled.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/shared/src/components/icons/Drag/index.tsx b/packages/shared/src/components/icons/Drag/index.tsx new file mode 100644 index 00000000000..de87f045780 --- /dev/null +++ b/packages/shared/src/components/icons/Drag/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const DragIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Drag/outlined.svg b/packages/shared/src/components/icons/Drag/outlined.svg new file mode 100644 index 00000000000..d667dff2d26 --- /dev/null +++ b/packages/shared/src/components/icons/Drag/outlined.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 11cf7a5c9b3..f5be181e6ed 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -49,6 +49,7 @@ export * from './Discuss'; export * from './Docs'; export * from './Download'; export * from './Downvote'; +export * from './Drag'; export * from './DrawnArrow'; export * from './Earth'; export * from './Edit'; diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index a2f6c2e3680..03885dcd39f 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -14,7 +14,12 @@ import { DropdownMenuOptions, DropdownMenuTrigger, } from '../../../components/dropdown/DropdownMenu'; -import { EditIcon, MenuIcon, TrashIcon } from '../../../components/icons'; +import { + DragIcon, + EditIcon, + MenuIcon, + TrashIcon, +} from '../../../components/icons'; import { MenuIcon as WrappingMenuIcon } from '../../../components/MenuIcon'; import { IconSize } from '../../../components/Icon'; import { combinedClicks } from '../../../lib/click'; @@ -218,7 +223,7 @@ export function ShortcutTile({ {...attributes} {...listeners} > - + )} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index fdc88ac6932..279d19e25e7 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -38,8 +38,8 @@ import { Switch } from '../../../../components/fields/Switch'; import { BookmarkIcon, DownloadIcon, + DragIcon, EditIcon, - MenuIcon, PlusIcon, SitesIcon, TrashIcon, @@ -105,7 +105,7 @@ function ShortcutRow({ {...attributes} {...listeners} > - + Date: Tue, 21 Apr 2026 14:55:02 +0300 Subject: [PATCH 06/32] feat(shortcuts): add mode selector and align hub UX with Chrome Introduces an explicit "My shortcuts" vs "Most visited sites" mode (persisted as shortcutsMode in SettingsFlags) so users stop guessing whether the hub is showing a live browser feed or their curated list: - Hub renders topSites read-only in auto mode, customLinks editable in manual mode; switching lives in the overflow menu and in a Chrome- style radio group inside Manage. - Auto mode with no topSites permission asks for access directly rather than piggy-backing on the import flow. - Drop the prominent red "Revoke top sites access" buttons from Manage; revocation is now a quiet menu entry, gated on actually-granted permissions (topSites/bookmarks !== undefined) instead of "checked". - Move the header "+ Add" into a dashed row at the top of the list so the add affordance sits next to the shortcuts it creates. - Import flow is now self-describing: always opens the picker (no more silent imports), entries show counts and grant-access hints, and the picker explains which source it came from. - Rename labels across the hub menu and CTAs to match Chrome vocabulary ("Most visited sites", "Bookmarks bar", "My shortcuts"). Made-with: Cursor --- .../ShortcutLinks/ShortcutImportFlow.tsx | 37 +-- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 216 ++++++++++++++---- .../components/modals/ImportPickerModal.tsx | 13 +- .../modals/ShortcutsManageModal.tsx | 210 ++++++++++++----- .../shared/src/features/shortcuts/types.ts | 4 + packages/shared/src/graphql/settings.ts | 3 +- 6 files changed, 339 insertions(+), 144 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx index 2eeeb80385b..c8f609289ea 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect, useRef } from 'react'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; -import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { @@ -30,7 +29,6 @@ export function ShortcutImportFlow(): ReactElement | null { askBookmarksPermission, } = useShortcuts(); const { customLinks } = useSettingsContext(); - const manager = useShortcutsManager(); const { displayToast } = useToastNotification(); const { openModal } = useLazyModal(); @@ -67,22 +65,11 @@ export function ShortcutImportFlow(): ReactElement | null { return; } + // Always show the picker so the user sees exactly what gets imported, + // which source it comes from, and can deselect items before confirming. + // Previously we silently imported when items fit in capacity, which was + // confusing ("what just got added? where from?"). const items = topSites.map((s) => ({ url: s.url })); - if (items.length <= capacity) { - manager - .importFrom('topSites', items) - .then((result) => { - displayToast( - `Imported ${result.imported} sites to shortcuts${ - result.skipped ? `. ${result.skipped} skipped.` : '' - }`, - ); - }) - .finally(() => { - setShowImportSource?.(null); - }); - return; - } openModal({ type: LazyModal.ImportPicker, props: { source: 'topSites', items }, @@ -116,21 +103,6 @@ export function ShortcutImportFlow(): ReactElement | null { } const items = bookmarks.map((b) => ({ url: b.url, title: b.title })); - if (items.length <= capacity) { - manager - .importFrom('bookmarks', items) - .then((result) => { - displayToast( - `Imported ${result.imported} bookmarks to shortcuts${ - result.skipped ? `. ${result.skipped} skipped.` : '' - }`, - ); - }) - .finally(() => { - setShowImportSource?.(null); - }); - return; - } openModal({ type: LazyModal.ImportPicker, props: { source: 'bookmarks', items }, @@ -144,7 +116,6 @@ export function ShortcutImportFlow(): ReactElement | null { bookmarks, hasCheckedBookmarksPermission, customLinks, - manager, displayToast, openModal, setShowImportSource, diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index 880c71bb077..ea130f58d44 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { closestCenter, @@ -29,6 +29,7 @@ import { } from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; import { BookmarkIcon, + EditIcon, EyeIcon, MenuIcon, PlusIcon, @@ -49,7 +50,10 @@ import { ShortcutsSourceType, TargetType, } from '@dailydotdev/shared/src/lib/log'; -import type { Shortcut } from '@dailydotdev/shared/src/features/shortcuts/types'; +import type { + Shortcut, + ShortcutsMode, +} from '@dailydotdev/shared/src/features/shortcuts/types'; import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; interface ShortcutLinksHubProps { @@ -60,10 +64,30 @@ export function ShortcutLinksHub({ shouldUseListFeedLayout, }: ShortcutLinksHubProps): ReactElement { const { openModal } = useLazyModal(); - const { toggleShowTopSites, showTopSites } = useSettingsContext(); + const { toggleShowTopSites, showTopSites, flags, updateFlag } = + useSettingsContext(); const { logEvent } = useLogContext(); const manager = useShortcutsManager(); - const { setShowImportSource } = useShortcuts(); + const { + setShowImportSource, + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + onRevokePermission, + bookmarks, + revokeBookmarksPermission, + } = useShortcuts(); + + // `undefined` means "permission not granted". An empty array means granted + // but nothing available. We only show Revoke entries when truly granted. + const hasTopSitesAccess = topSites !== undefined; + const hasBookmarksAccess = bookmarks !== undefined; + + // Default to 'manual' so existing users keep their curated lists. Auto mode + // is opt-in via the overflow menu (users who grant topSites permission and + // prefer Chrome-style live tiles). + const mode: ShortcutsMode = flags?.shortcutsMode ?? 'manual'; + const isAuto = mode === 'auto'; const sensors = useSensors( useSensor(PointerSensor, { @@ -84,47 +108,63 @@ export function ShortcutLinksHub({ justDraggedRef.current = false; }; - const loggedRef = useRef(false); + const loggedRef = useRef(null); useEffect(() => { - if (loggedRef.current || !showTopSites) { + if (!showTopSites) { return; } - loggedRef.current = true; + if (loggedRef.current === mode) { + return; + } + loggedRef.current = mode; logEvent({ event_name: LogEvent.Impression, target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), + extra: JSON.stringify({ + source: isAuto + ? ShortcutsSourceType.Browser + : ShortcutsSourceType.Custom, + }), }); - }, [logEvent, showTopSites]); + }, [logEvent, showTopSites, mode, isAuto]); const [reorderAnnouncement, setReorderAnnouncement] = useState(''); - // Defensive cap: never render more than MAX_SHORTCUTS tiles on the new tab - // even if `customLinks` somehow contains more (legacy data, cross-device - // sync, direct settings mutation). Overflow stays visible + removable in - // the Manage modal. Mirrors Chrome's new-tab behaviour of a fixed cap. - const visibleShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); - const overflowCount = manager.shortcuts.length - visibleShortcuts.length; + // Auto mode: render live top sites from the browser (read-only). + // Manual mode: render the curated customLinks (editable). + const autoShortcuts: Shortcut[] = useMemo( + () => + (topSites ?? []) + .slice(0, MAX_SHORTCUTS) + .map((site) => ({ url: site.url, name: site.title || undefined })), + [topSites], + ); + const manualShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); + const overflowCount = isAuto + ? 0 + : manager.shortcuts.length - manualShortcuts.length; + const visibleShortcuts = isAuto ? autoShortcuts : manualShortcuts; const handleDragEnd = (event: DragEndEvent) => { justDraggedRef.current = true; + if (isAuto) { + return; + } const { active, over } = event; if (!over || active.id === over.id) { return; } - const urls = visibleShortcuts.map((s) => s.url); + const urls = manualShortcuts.map((s) => s.url); const oldIndex = urls.indexOf(active.id as string); const newIndex = urls.indexOf(over.id as string); if (oldIndex < 0 || newIndex < 0) { return; } - // Reorder affects only the visible window; append any overflow so we - // don't silently drop them from customLinks. const overflowUrls = manager.shortcuts .slice(MAX_SHORTCUTS) .map((s) => s.url); manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); - const moved = visibleShortcuts[oldIndex]; + const moved = manualShortcuts[oldIndex]; const label = moved?.name || moved?.url || 'Shortcut'; setReorderAnnouncement( `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, @@ -135,7 +175,11 @@ export function ShortcutLinksHub({ logEvent({ event_name: LogEvent.Click, target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), + extra: JSON.stringify({ + source: isAuto + ? ShortcutsSourceType.Browser + : ShortcutsSourceType.Custom, + }), }); const onEdit = (shortcut: Shortcut) => @@ -151,33 +195,95 @@ export function ShortcutLinksHub({ const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); - const menuOptions = [ - { - icon: , - label: 'Add shortcut', - action: onAdd, - }, - { - icon: , - label: 'Import from browser', - action: () => setShowImportSource?.('topSites'), - }, - { - icon: , - label: 'Import from bookmarks', - action: () => setShowImportSource?.('bookmarks'), - }, - { - icon: , - label: 'Hide', - action: toggleShowTopSites, - }, - { - icon: , - label: 'Manage', - action: onManage, - }, - ]; + const requestTopSitesAccess = async () => { + // Unlike the import flow, we only need READ access here — we're not + // copying sites into customLinks, just rendering whatever the browser + // exposes. If the user declines we stay on the empty-state CTA. + const granted = await askTopSitesPermission(); + return granted; + }; + + const switchToAuto = async () => { + await updateFlag('shortcutsMode', 'auto'); + if (!hasCheckedTopSitesPermission || topSites === undefined) { + await requestTopSitesAccess(); + } + }; + + const switchToManual = () => updateFlag('shortcutsMode', 'manual'); + + const revokeTopSitesItem = hasTopSitesAccess + ? [ + { + icon: , + label: 'Revoke Most visited sites access', + action: onRevokePermission, + }, + ] + : []; + const revokeBookmarksItem = + hasBookmarksAccess && revokeBookmarksPermission + ? [ + { + icon: , + label: 'Revoke Bookmarks bar access', + action: () => revokeBookmarksPermission(), + }, + ] + : []; + + const menuOptions = isAuto + ? [ + { + icon: , + label: 'Switch to My shortcuts', + action: switchToManual, + }, + ...revokeTopSitesItem, + { + icon: , + label: 'Hide shortcuts', + action: toggleShowTopSites, + }, + ] + : [ + { + icon: , + label: 'Add shortcut', + action: onAdd, + }, + { + icon: , + label: 'Switch to Most visited sites', + action: switchToAuto, + }, + { + icon: , + label: 'Import from Most visited sites', + action: () => setShowImportSource?.('topSites'), + }, + { + icon: , + label: 'Import from Bookmarks bar', + action: () => setShowImportSource?.('bookmarks'), + }, + { + icon: , + label: 'Manage', + action: onManage, + }, + ...revokeTopSitesItem, + ...revokeBookmarksItem, + { + icon: , + label: 'Hide shortcuts', + action: toggleShowTopSites, + }, + ]; + + // Auto mode with no permission yet: show a clear CTA tile so the user knows + // why the row is empty and can grant access or switch back to manual. + const showAutoEmptyState = isAuto && visibleShortcuts.length === 0; return (
))} - {manager.canAdd && } + {!isAuto && manager.canAdd && } + {showAutoEmptyState && ( + + )} {overflowCount > 0 && (
+ {showTopSites && ( + <> + +
+ Shortcuts source + selectMode('manual')} + title="My shortcuts" + description="Shortcuts are curated by you — add, edit, remove, and reorder them." + /> + selectMode('auto')} + title="Most visited sites" + description="Shortcuts are suggested based on websites you visit often." + /> +
+ + )} + {manager.shortcuts.length === 0 ? ( @@ -347,16 +443,37 @@ export default function ShortcutsManageModal(
) : ( - - s.url)} - strategy={verticalListSortingStrategy} +
+ + -
+ s.url)} + strategy={verticalListSortingStrategy} + > {manager.shortcuts.map((shortcut) => ( ))} -
- -
- )} - - {(hasCheckedPermission || hasBookmarksPermission) && ( -
- {hasCheckedPermission && ( - - )} - {hasBookmarksPermission && revokeBookmarksPermission && ( - - )} + +
)} + + {/* Permission revocation moved to the hub's overflow menu — those + actions belong with the permission-gated features (auto mode, + browser import) and don't need to be primary CTAs here. */}
diff --git a/packages/shared/src/features/shortcuts/types.ts b/packages/shared/src/features/shortcuts/types.ts index 01760576d07..dbe502668a4 100644 --- a/packages/shared/src/features/shortcuts/types.ts +++ b/packages/shared/src/features/shortcuts/types.ts @@ -30,5 +30,9 @@ export type Shortcut = { 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'; + export const MAX_SHORTCUTS = 12; export const UNDO_TIMEOUT_MS = 6000; diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 7212f1d14b1..bc89f2287c7 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -1,7 +1,7 @@ import { gql } from 'graphql-request'; import type { SortCommentsBy } from './comments'; import type { WriteFormTab } from '../components/fields/form/common'; -import type { ShortcutMeta } from '../features/shortcuts/types'; +import type { ShortcutMeta, ShortcutsMode } from '../features/shortcuts/types'; export type Spaciness = 'eco' | 'roomy' | 'cozy'; export type RemoteTheme = 'darcula' | 'bright' | 'auto'; @@ -22,6 +22,7 @@ export type SettingsFlags = { prompt?: Record; defaultWriteTab?: WriteFormTab; shortcutMeta?: Record; + shortcutsMode?: ShortcutsMode; }; export enum SidebarSettingsFlags { From 5a6a391ac39b2431422e41e2491926d007c9aa87 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:55:14 +0300 Subject: [PATCH 07/32] refactor(shortcuts): redesign edit modal with icon-first layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old modal led with a boxed preview of a ShortcutTile on a gradient backdrop, which duplicated the form inputs and looked odd when the URL was still the placeholder. Replace it with an icon-first layout: - Single 96px avatar at the top. Defaults to the live favicon derived from the URL field as the user types; falls back to EarthIcon when the URL is empty or invalid. Click opens the file picker; hover reveals an Upload/Replace band. - Custom icon uploads use useFileInput + uploadContentImage directly (replacing ImageInput, which couldn't react to URL-driven favicon changes because of its internal state). - Helper line under the avatar explains the current state in plain language ("Using site favicon — click to upload your own." / "Remove custom icon" / "Uploading…"). - Reorder fields to Image → Name → URL per design feedback. The "Or paste an image URL instead" escape hatch stays but moves below the main fields. Made-with: Cursor --- .../components/modals/ShortcutEditModal.tsx | 207 +++++++++++------- 1 file changed, 127 insertions(+), 80 deletions(-) diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 207e85bc300..22f5e3c0aaa 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useMemo, useState } 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'; @@ -9,20 +9,20 @@ import { ButtonVariant, } from '../../../../components/buttons/Button'; import ControlledTextField from '../../../../components/fields/ControlledTextField'; -import ImageInput from '../../../../components/fields/ImageInput'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; import { Justify } from '../../../../components/utilities'; import { useShortcutsManager } from '../../hooks/useShortcutsManager'; -import { ShortcutTile } from '../ShortcutTile'; import type { Shortcut } from '../../types'; import { isValidHttpUrl, withHttps } from '../../../../lib/links'; -import { CameraIcon } from '../../../../components/icons'; +import { CameraIcon, EarthIcon } from '../../../../components/icons'; import { imageSizeLimitMB, uploadContentImage, } from '../../../../graphql/posts'; +import { useFileInput } from '../../../../hooks/utils/useFileInput'; import { useToastNotification } from '../../../../hooks/useToastNotification'; +import { apiUrl } from '../../../../lib/config'; const schema = z.object({ name: z @@ -86,12 +86,10 @@ export default function ShortcutEditModal({ formState: { isSubmitting }, } = methods; - const handleIconUpload = async (base64: string | null, file?: File) => { - if (!file || !base64) { - clearErrors('iconUrl'); - setValue('iconUrl', '', { shouldDirty: true }); - return; - } + const fileInputRef = useRef(null); + const [faviconFailed, setFaviconFailed] = useState(false); + + const handleIconBase64 = async (base64: string, file: File) => { clearErrors('iconUrl'); // Show the base64 preview immediately while the upload finishes. setValue('iconUrl', base64, { shouldDirty: true }); @@ -110,17 +108,37 @@ export default function ShortcutEditModal({ } }; + const { onFileChange } = useFileInput({ + limitMb: imageSizeLimitMB, + onChange: handleIconBase64, + }); + const values = watch(); - const previewShortcut = useMemo( - () => ({ - url: values.url || 'https://example.com', - name: values.name || undefined, - iconUrl: values.iconUrl || undefined, - // Fallback color is derived from the URL in ShortcutTile when omitted, - // so the preview still looks right without a user-selected color. - }), - [values.iconUrl, values.name, values.url], - ); + + // Reset the favicon-failed flag whenever the user edits the URL, so typing + // past a transiently-broken state recovers and shows the new favicon. + useEffect(() => { + setFaviconFailed(false); + }, [values.url]); + + // Decide what to render inside the icon avatar: + // 1. A custom icon (uploaded, base64 preview, or pasted URL) — takes priority. + // 2. Otherwise the site's favicon, derived from the URL as the user types. + // 3. If neither is available, fall back to a neutral Earth glyph so the + // control still looks like "a picker", not an empty circle. + const hasCustomIcon = !!values.iconUrl; + const urlCandidate = values.url ? withHttps(values.url) : ''; + 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 onSubmit = handleSubmit(async (data) => { const payload = { @@ -153,10 +171,79 @@ export default function ShortcutEditModal({
-
-
-
- + {/* Icon-first: a single tappable avatar at the top. The favicon + derived from the URL fills it by default; uploading swaps it + out. No secondary preview tile — users don't need to see the + shortcut re-rendered to know what it'll look like. */} +
+ + { + onFileChange(event.target.files?.[0] ?? null); + // Reset so the same file can be reselected after clearing. + event.target.value = ''; + }} + /> +
+ {isUploading ? ( + Uploading… + ) : hasCustomIcon ? ( + + ) : ( + + {faviconSrc + ? 'Using site favicon — click to upload your own.' + : 'Click to upload an icon. Leave empty to use the site favicon.'} + + )} +
@@ -171,62 +258,22 @@ export default function ShortcutEditModal({ label="URL" placeholder="https://example.com" /> -
- - Custom icon (optional) - -
- } - className={{ - container: classNames( - 'rounded-14 border-border-subtlest-tertiary bg-surface-float', - isUploading && 'opacity-60', - ), - }} - > - - -
- - Upload an image or drag & drop. Leave empty to use the - site favicon. - - {isUploading && ( - - Uploading… - - )} -
-
- - {showUrlInput && ( -
- -
- )} -
+ + {showUrlInput && ( + + )}
From 090921653d475a0ed778da443b8437665e5d125f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 17:10:56 +0300 Subject: [PATCH 08/32] feat(shortcuts): redesign hub menu, import modal, and appearance options - Simplify overflow menu to 4 stable items with an inline mode toggle so it no longer reshuffles when the source flips. - Move import, revoke, and restore-hidden actions into the Manage modal under a new Browser connections section. - Add tile/icon/chip appearance modes with a live-preview picker in the Manage modal, wired through SettingsContext. - Refactor Import picker to a tap-to-select list with favicon fallbacks, segmented capacity pips, and domain-only labels. - Polish ShortcutTile, AddShortcutTile, and Edit modal for a calmer, higher-contrast visual language across themes. Made-with: Cursor --- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 257 ++++++++----- .../shortcuts/components/AddShortcutTile.tsx | 76 +++- .../shortcuts/components/ShortcutTile.tsx | 290 +++++++++----- .../components/modals/ImportPickerModal.tsx | 298 ++++++++++----- .../components/modals/ShortcutEditModal.tsx | 32 +- .../modals/ShortcutsManageModal.tsx | 361 ++++++++++++++++-- .../shortcuts/hooks/useHiddenTopSites.ts | 55 +++ .../shared/src/features/shortcuts/types.ts | 9 + packages/shared/src/graphql/settings.ts | 7 +- 9 files changed, 1042 insertions(+), 343 deletions(-) create mode 100644 packages/shared/src/features/shortcuts/hooks/useHiddenTopSites.ts diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index ea130f58d44..65d75bab50f 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -24,12 +24,11 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuOptions, DropdownMenuTrigger, } from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; import { - BookmarkIcon, - EditIcon, EyeIcon, MenuIcon, PlusIcon, @@ -42,9 +41,11 @@ import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/type import { ShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/ShortcutTile'; import { AddShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/AddShortcutTile'; import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useHiddenTopSites } from '@dailydotdev/shared/src/features/shortcuts/hooks/useHiddenTopSites'; import { 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, @@ -52,9 +53,13 @@ import { } from '@dailydotdev/shared/src/lib/log'; import type { Shortcut, + ShortcutsAppearance, ShortcutsMode, } from '@dailydotdev/shared/src/features/shortcuts/types'; -import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; +import { + DEFAULT_SHORTCUTS_APPEARANCE, + MAX_SHORTCUTS, +} from '@dailydotdev/shared/src/features/shortcuts/types'; interface ShortcutLinksHubProps { shouldUseListFeedLayout: boolean; @@ -67,27 +72,26 @@ export function ShortcutLinksHub({ const { toggleShowTopSites, showTopSites, flags, updateFlag } = useSettingsContext(); const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); const manager = useShortcutsManager(); const { - setShowImportSource, + hidden: hiddenTopSites, + hide: hideTopSite, + unhide: unhideTopSite, + } = useHiddenTopSites(); + const { topSites, hasCheckedPermission: hasCheckedTopSitesPermission, askTopSitesPermission, - onRevokePermission, - bookmarks, - revokeBookmarksPermission, } = useShortcuts(); - // `undefined` means "permission not granted". An empty array means granted - // but nothing available. We only show Revoke entries when truly granted. - const hasTopSitesAccess = topSites !== undefined; - const hasBookmarksAccess = bookmarks !== undefined; - // Default to 'manual' so existing users keep their curated lists. Auto mode // is opt-in via the overflow menu (users who grant topSites permission and // prefer Chrome-style live tiles). const mode: ShortcutsMode = flags?.shortcutsMode ?? 'manual'; const isAuto = mode === 'auto'; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; const sensors = useSensors( useSensor(PointerSensor, { @@ -98,7 +102,14 @@ export function ShortcutLinksHub({ }), ); + // dnd-kit activates drag via pointer events; browsers still synthesize a + // `click` on `pointerup` over the anchor because the element follows the + // pointer (no relative movement). We flag the drag lifecycle and swallow the + // synthesized click in the capture phase so the link never navigates. const justDraggedRef = useRef(false); + const armDragSuppression = () => { + justDraggedRef.current = true; + }; const suppressClickCapture = (event: React.MouseEvent) => { if (!justDraggedRef.current) { return; @@ -130,14 +141,19 @@ export function ShortcutLinksHub({ const [reorderAnnouncement, setReorderAnnouncement] = useState(''); - // Auto mode: render live top sites from the browser (read-only). - // Manual mode: render the curated customLinks (editable). + // Auto mode: render live top sites from the browser, minus any the user + // dismissed (Chrome-style). Manual mode: render the curated customLinks. + const hiddenTopSitesSet = useMemo( + () => new Set(hiddenTopSites), + [hiddenTopSites], + ); const autoShortcuts: Shortcut[] = useMemo( () => (topSites ?? []) + .filter((site) => !hiddenTopSitesSet.has(site.url)) .slice(0, MAX_SHORTCUTS) .map((site) => ({ url: site.url, name: site.title || undefined })), - [topSites], + [topSites, hiddenTopSitesSet], ); const manualShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); const overflowCount = isAuto @@ -146,7 +162,7 @@ export function ShortcutLinksHub({ const visibleShortcuts = isAuto ? autoShortcuts : manualShortcuts; const handleDragEnd = (event: DragEndEvent) => { - justDraggedRef.current = true; + armDragSuppression(); if (isAuto) { return; } @@ -190,6 +206,20 @@ export function ShortcutLinksHub({ const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + // Chrome-style dismiss for auto mode: hide the tile for this browser and + // offer a single-action "Undo" toast. We can't delete the site from the + // browser's history, so we just remember the URL locally. + const onHideTopSite = (shortcut: Shortcut) => { + hideTopSite(shortcut.url); + const label = shortcut.name || shortcut.url; + displayToast(`Hidden ${label}`, { + action: { + copy: 'Undo', + onClick: () => unhideTopSite(shortcut.url), + }, + }); + }; + const onAdd = () => openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); @@ -212,74 +242,39 @@ export function ShortcutLinksHub({ const switchToManual = () => updateFlag('shortcutsMode', 'manual'); - const revokeTopSitesItem = hasTopSitesAccess - ? [ - { - icon: , - label: 'Revoke Most visited sites access', - action: onRevokePermission, - }, - ] - : []; - const revokeBookmarksItem = - hasBookmarksAccess && revokeBookmarksPermission - ? [ - { - icon: , - label: 'Revoke Bookmarks bar access', - action: () => revokeBookmarksPermission(), - }, - ] - : []; + // The overflow menu is the same shape in both modes — source selection is + // an inline toggle at the top, so users never see items appear/disappear + // after flipping mode. "Add shortcut" stays visible but is disabled in auto + // so the placement doesn't jump. + const toggleSourceMode = () => { + if (isAuto) { + switchToManual(); + } else { + switchToAuto(); + } + }; - const menuOptions = isAuto - ? [ - { - icon: , - label: 'Switch to My shortcuts', - action: switchToManual, - }, - ...revokeTopSitesItem, - { - icon: , - label: 'Hide shortcuts', - action: toggleShowTopSites, - }, - ] - : [ - { - icon: , - label: 'Add shortcut', - action: onAdd, - }, - { - icon: , - label: 'Switch to Most visited sites', - action: switchToAuto, - }, - { - icon: , - label: 'Import from Most visited sites', - action: () => setShowImportSource?.('topSites'), - }, - { - icon: , - label: 'Import from Bookmarks bar', - action: () => setShowImportSource?.('bookmarks'), - }, - { - icon: , - label: 'Manage', - action: onManage, - }, - ...revokeTopSitesItem, - ...revokeBookmarksItem, - { - icon: , - label: 'Hide shortcuts', - action: toggleShowTopSites, - }, - ]; + const menuOptions = [ + { + icon: , + label: 'Add shortcut', + action: onAdd, + disabled: isAuto, + ariaLabel: isAuto + ? 'Add shortcut (available in My shortcuts mode)' + : 'Add shortcut', + }, + { + icon: , + label: 'Manage shortcuts…', + action: onManage, + }, + { + icon: , + label: 'Hide shortcuts', + action: toggleShowTopSites, + }, + ]; // Auto mode with no permission yet: show a clear CTA tile so the user knows // why the row is empty and can grant access or switch back to manual. @@ -292,13 +287,20 @@ export function ShortcutLinksHub({ onClickCapture={suppressClickCapture} onAuxClickCapture={suppressClickCapture} className={classNames( - 'hidden flex-wrap items-start gap-x-3 gap-y-4 tablet:flex', + 'hidden flex-wrap items-center tablet:flex', + // Gap scales with density: tiles have labels so they need breathing + // room; chips/icons pack tighter like a real bookmarks bar. + appearance === 'tile' && 'gap-x-2 gap-y-3 items-start', + appearance === 'icon' && 'gap-1.5', + appearance === 'chip' && 'gap-1.5', shouldUseListFeedLayout ? 'mx-6 mb-3 mt-1' : 'mb-5', )} > ))} - {!isAuto && manager.canAdd && } + {!isAuto && manager.canAdd && ( + + )} {showAutoEmptyState && ( @@ -331,7 +337,11 @@ export function ShortcutLinksHub({ @@ -346,14 +356,79 @@ export function ShortcutLinksHub({ variant={ButtonVariant.Tertiary} size={ButtonSize.Small} icon={} - className="mt-2 transition-transform duration-150 hover:-translate-y-0.5 motion-reduce:transition-none motion-reduce:hover:translate-y-0" - aria-label="toggle shortcuts menu" + className={classNames( + 'ml-1 !size-8 !min-w-0 rounded-full text-text-tertiary transition-colors duration-150 hover:bg-surface-float hover:text-text-primary motion-reduce:transition-none', + appearance === 'tile' && 'mt-2', + )} + aria-label="Shortcut options" /> - + + +
); } + +interface SourceModeToggleItemProps { + isAuto: boolean; + onToggle: () => void; +} + +// A stable menu row that flips source mode in place. Lives inside the +// DropdownMenuContent so the surrounding options don't shuffle when the mode +// changes. We call `preventDefault` on `onSelect` so the menu stays open after +// toggling, matching the mental model of "I'm adjusting a setting, not +// triggering an action". +function SourceModeToggleItem({ + isAuto, + onToggle, +}: SourceModeToggleItemProps): ReactElement { + return ( + { + event.preventDefault(); + onToggle(); + }} + className="!items-start !gap-3 !py-2.5" + > + + + + + + Most visited sites + + + Auto-fill from your browsing history + + + + + ); +} + +// Visual-only switch. The enclosing menu item owns click + keyboard handling. +function SwitchTrack({ checked }: { checked: boolean }): ReactElement { + return ( + + + + ); +} diff --git a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx index 798efe36c4d..60f55d08f2d 100644 --- a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx @@ -3,38 +3,88 @@ import React from 'react'; import classNames from 'classnames'; import { PlusIcon } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; +import type { ShortcutsAppearance } from '../types'; interface AddShortcutTileProps { onClick: () => void; + appearance?: ShortcutsAppearance; disabled?: boolean; } +// Mirrors ShortcutTile's three appearance layouts so the row stays visually +// coherent across modes. Dashed outline on the icon slot signals "empty" +// without competing with the real tiles around it. export function AddShortcutTile({ onClick, + appearance = 'tile', disabled, }: AddShortcutTileProps): ReactElement { + const isChip = appearance === 'chip'; + const isIconOnly = appearance === 'icon'; + + const iconBox = ( + + + + ); + + if (isChip) { + return ( + + ); + } + + if (isIconOnly) { + return ( + + ); + } + return ( diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index 03885dcd39f..29d2ff57111 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -1,13 +1,13 @@ -import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'; -import React, { useCallback, useState } from 'react'; +import type { + KeyboardEvent, + MouseEvent, + PointerEvent as ReactPointerEvent, + ReactElement, +} from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import classNames from 'classnames'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; import { DropdownMenu, DropdownMenuContent, @@ -15,17 +15,21 @@ import { DropdownMenuTrigger, } from '../../../components/dropdown/DropdownMenu'; import { - DragIcon, EditIcon, MenuIcon, + MiniCloseIcon, TrashIcon, } from '../../../components/icons'; -import { MenuIcon as WrappingMenuIcon } from '../../../components/MenuIcon'; 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 type { Shortcut, ShortcutColor } from '../types'; +import type { + Shortcut, + ShortcutColor, + ShortcutsAppearance, +} from '../types'; const pixelRatio = typeof globalThis?.window === 'undefined' @@ -42,39 +46,31 @@ const colorClass: Record = { cabbage: 'bg-accent-cabbage-bolder text-white', }; -const colorGlowClass: Record = { - burger: 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-burger-default)/0.45)]', - cheese: - 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-cheese-default)/0.45)]', - avocado: - 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-avocado-default)/0.45)]', - bacon: - 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-bacon-default)/0.45)]', - blueCheese: - 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-blueCheese-default)/0.45)]', - cabbage: - 'group-hover:shadow-[0_8px_24px_-8px_rgb(var(--theme-accent-cabbage-default)/0.45)]', -}; - interface LetterChipProps { name: string; color: ShortcutColor; - size?: 'sm' | 'lg'; + size?: 'sm' | 'md' | 'lg'; } function LetterChip({ name, color, - size = 'sm', + size = 'md', }: LetterChipProps): ReactElement { const letter = (name || '?').charAt(0).toUpperCase(); + const sizeClass = + size === 'lg' + ? 'size-10 text-lg' + : size === 'sm' + ? 'size-6 text-xs' + : 'size-8 text-sm'; return ( {letter} @@ -84,19 +80,23 @@ function LetterChip({ 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, color = 'burger' } = shortcut; @@ -129,16 +129,39 @@ export function ShortcutTile({ [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 >= 25; + }, + [], + ); + const handleAnchorClick = useCallback( (event: MouseEvent) => { - if (isDragging) { + if (isDragging || didPointerTravel(event)) { event.preventDefault(); event.stopPropagation(); return; } onClick?.(); }, - [isDragging, onClick], + [didPointerTravel, isDragging, onClick], ); const finalIconSrc = @@ -155,91 +178,168 @@ export function ShortcutTile({ event.stopPropagation(); }; - const menuOptions = [ - ...(onEdit - ? [ - { - icon: , - label: 'Edit', - action: () => onEdit(shortcut), - }, - ] - : []), - ...(onRemove - ? [ - { - icon: , - label: 'Remove', - action: () => onRemove(shortcut), - }, - ] - : []), - ]; + 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 } : {}; + + const isChip = appearance === 'chip'; + const isIconOnly = appearance === 'icon'; + + // Favicon/letter renderer, sized per appearance. Chip mode uses a smaller + // 16px glyph to fit the compact pill; tile/icon modes stay at the roomier + // 24px favicon the rest of the feature uses. + const iconContent = shouldShowFavicon ? ( + + ) : ( + + ); + + // Anchor (the clickable favicon box). Tile/icon modes make it the whole + // square; chip mode makes it a compact slot inside a horizontal pill. + const anchorCommon = { + href: url, + rel: 'noopener noreferrer', + onPointerDown: handlePointerDown, + onKeyDown: handleKey, + 'aria-label': label, + }; + + // Outer container styling per appearance: + // - tile : 76px-wide column with label underneath (Chrome new tab). + // - icon : compact square (iOS dock / Arc pinned tabs). + // - chip : horizontal pill with favicon + label (Chrome bookmarks bar). + const containerClass = classNames( + 'group relative outline-none transition-colors duration-150 ease-out motion-reduce:transition-none', + isChip + ? 'flex h-9 max-w-[200px] items-center gap-2 rounded-10 bg-surface-float pl-2 pr-2 hover:bg-background-default focus-within:bg-background-default' + : isIconOnly + ? 'flex size-12 items-center justify-center rounded-12 hover:bg-surface-float focus-within:bg-surface-float' + : 'flex w-[76px] flex-col items-center rounded-14 p-2 hover:bg-surface-float focus-within:bg-surface-float', + draggable && 'cursor-grab active:cursor-grabbing', + isDragging && + 'z-10 rotate-[-2deg] bg-surface-float shadow-2 motion-reduce:rotate-0', + className, + ); + + // Action button position changes with layout so it always sits on an + // outside corner rather than over the label. + const actionBtnPositionClass = isChip + ? 'absolute -right-1 -top-1' + : 'absolute right-0.5 top-0.5'; return (
- - {shouldShowFavicon ? ( - {label} - ) : ( - - )} - - - {label} - - - {draggable && ( + {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 && ( - diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx index 36080cb4519..324eefa21c2 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -2,11 +2,13 @@ import type { ReactElement } from 'react'; import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { Button, ButtonVariant } from '../../../../components/buttons/Button'; -import { Checkbox } from '../../../../components/fields/Checkbox'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; import { Justify } from '../../../../components/utilities'; +import { 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'; @@ -24,6 +26,42 @@ export interface ImportPickerModalProps extends ModalProps { onImported?: (result: { imported: number; skipped: number }) => void; } +// Favicon with graceful fallback: the browser-icon proxy often ships a blurry +// 16px globe for sites it doesn't know. Instead of rendering that fuzz, we +// swap to a letter chip painted from the site's first character. +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, @@ -34,10 +72,7 @@ export default function ImportPickerModal({ const manager = useShortcutsManager(); const { displayToast } = useToastNotification(); - const capacity = Math.max( - 0, - MAX_SHORTCUTS - (customLinks?.length ?? 0), - ); + const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); const [checked, setChecked] = useState>(() => { const state: Record = {}; items.slice(0, capacity).forEach((item) => { @@ -51,21 +86,18 @@ export default function ImportPickerModal({ [checked, items], ); - const toggle = (url: string, next: boolean) => - setChecked((prev) => ({ ...prev, [url]: next })); + const selectableCount = Math.min(items.length, capacity); + const atCapacity = selected.length >= capacity; - const handleImport = async () => { - const result = await manager.importFrom(source, selected); - onImported?.(result); - displayToast( - `Imported ${result.imported} ${ - source === 'bookmarks' ? 'bookmarks' : 'sites' - } to shortcuts${result.skipped ? `. ${result.skipped} skipped.` : ''}`, - ); - props.onRequestClose?.(undefined as never); - }; + const toggle = (url: string) => + setChecked((prev) => { + const next = !prev[url]; + if (next && !prev[url] && selected.length >= capacity) { + return prev; + } + return { ...prev, [url]: next }; + }); - const selectableCount = Math.min(items.length, capacity); const allSelected = selectableCount > 0 && selected.length >= selectableCount; const toggleAll = () => { @@ -80,96 +112,170 @@ export default function ImportPickerModal({ setChecked(next); }; + const handleImport = async () => { + const result = await manager.importFrom(source, selected); + onImported?.(result); + displayToast( + `Imported ${result.imported} ${ + source === 'bookmarks' ? 'bookmarks' : 'sites' + } to shortcuts${result.skipped ? `. ${result.skipped} skipped.` : ''}`, + ); + props.onRequestClose?.(undefined as never); + }; + + const isBookmarks = source === 'bookmarks'; + const title = isBookmarks ? 'Import bookmarks' : 'Import most visited'; + const subtitle = isBookmarks + ? 'Tap to pick. Your bookmarks stay untouched.' + : 'Tap to pick. Added as a snapshot of your history.'; + + // Segmented capacity meter. Rather than a thin progress line that reads as + // a random pink scratch, we render one pip per slot. Filled pips are your + // picks; empty pips are the room you still have. Lights up like a battery. + const pips = Array.from({ length: Math.max(capacity, 1) }); + return ( - - {source === 'bookmarks' - ? 'Import from your bookmarks bar' - : 'Import from your most visited sites'} - +
+ {title} +

+ {subtitle} +

+
-

- {source === 'bookmarks' - ? `Showing ${items.length} ${ - items.length === 1 ? 'bookmark' : 'bookmarks' - } pinned to your browser's bookmarks bar. Pick the ones to add to your shortcuts — selected items will be copied, your bookmarks stay untouched.` - : `Showing ${items.length} ${ - items.length === 1 ? 'site' : 'sites' - } your browser tracks as most visited. Pick the ones to add to your shortcuts — selected items will be copied as a one-time snapshot.`} -

-
-

- - {selected.length} - {' '} - of {capacity} slots selected -

- +
-
    - {items.map((item) => { - const isChecked = !!checked[item.url]; - const atCap = !isChecked && selected.length >= capacity; - return ( -
  • - toggle(item.url, next)} - /> - -
    -

    - {item.title || item.url} -

    -

    - {item.url} -

    -
    -
  • - ); - })} -
+ + {items.length === 0 ? ( +
+ + {isBookmarks + ? 'Your bookmarks bar is empty.' + : 'No most visited sites yet.'} + +
+ ) : ( + // Tap-to-toggle rows. No separate checkbox column. Selected state is + // a check badge on the icon itself (iOS Photos multi-select feel) + // plus a calm surface tint. Dead quiet until you interact. +
    + {items.map((item) => { + const isChecked = !!checked[item.url]; + const atCap = !isChecked && atCapacity; + const label = item.title || getDomainFromUrl(item.url); + return ( +
  • + +
  • + ); + })} +
+ )}
- - - + + + {atCapacity + ? 'Capacity reached' + : `${Math.max(0, capacity - selected.length)} slot${ + capacity - selected.length === 1 ? '' : 's' + } left`} + +
+ + +
); diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 22f5e3c0aaa..d2df0c116a2 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -175,7 +175,7 @@ export default function ShortcutEditModal({ derived from the URL fills it by default; uploading swaps it out. No secondary preview tile — users don't need to see the shortcut re-rendered to know what it'll look like. */} -
+
-
+
{isUploading ? ( - Uploading… + + + Uploading… + ) : hasCustomIcon ? ( ) : ( {faviconSrc - ? 'Using site favicon — click to upload your own.' - : 'Click to upload an icon. Leave empty to use the site favicon.'} + ? 'Using site favicon. Click the avatar to upload your own.' + : 'Click the avatar to upload an icon.'} )}
@@ -261,7 +269,7 @@ export default function ShortcutEditModal({
); } +// Appearance picker: three preset cards with tiny live previews so users +// see what they're picking. Pattern is borrowed from Raindrop.io's layout +// switcher and Notion's view picker — one click changes the row style of +// the whole toolbar. +function AppearancePicker({ + value, + onChange, +}: { + value: ShortcutsAppearance; + onChange: (next: ShortcutsAppearance) => void; +}): ReactElement { + const options: Array<{ + id: ShortcutsAppearance; + title: string; + description: string; + preview: ReactElement; + }> = [ + { + id: 'tile', + title: 'Tile', + description: 'Icon with label below — Chrome new-tab style.', + preview: ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ), + }, + { + id: 'icon', + title: 'Icon', + description: 'Just the favicon. Minimal, like a dock.', + preview: ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ), + }, + { + id: 'chip', + title: 'Chip', + description: 'Favicon + name in a pill — bookmarks bar.', + preview: ( +
+ {[0, 1].map((i) => ( +
+
+
+
+ ))} +
+ ), + }, + ]; + + return ( +
+ + + Appearance + + + Choose how shortcuts look + + +
+ {options.map((opt) => { + const checked = value === opt.id; + return ( + + ); + })} +
+
+ ); +} + export default function ShortcutsManageModal( props: ModalProps, ): ReactElement { @@ -208,9 +353,15 @@ export default function ShortcutsManageModal( topSites, hasCheckedPermission: hasCheckedTopSitesPermission, askTopSitesPermission, + onRevokePermission, bookmarks, hasCheckedBookmarksPermission, + revokeBookmarksPermission, } = useShortcuts(); + const { + hidden: hiddenTopSites, + restore: restoreHiddenTopSites, + } = useHiddenTopSites(); const { openModal } = useLazyModal(); const mode = flags?.shortcutsMode ?? 'manual'; @@ -224,6 +375,15 @@ export default function ShortcutsManageModal( } }; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + const selectAppearance = (next: ShortcutsAppearance) => { + if (next === appearance) { + return; + } + updateFlag('shortcutsAppearance', next); + }; + const topSitesCount = topSites?.length ?? 0; const bookmarksCount = bookmarks?.length ?? 0; const topSitesKnown = hasCheckedTopSitesPermission && topSites !== undefined; @@ -387,14 +547,21 @@ export default function ShortcutsManageModal( description="Shortcuts are suggested based on websites you visit often." /> + + + + )} {manager.shortcuts.length === 0 ? ( -
- +
+
@@ -443,15 +610,15 @@ export default function ShortcutsManageModal(
) : ( -
+
); } + +interface BrowserConnectionsSectionProps { + topSitesGranted: boolean; + bookmarksGranted: boolean; + hiddenCount: number; + onRevokeTopSites?: () => void | Promise; + onRevokeBookmarks?: () => void | Promise; + onRestoreHidden: () => void; +} + +function BrowserConnectionsSection({ + topSitesGranted, + bookmarksGranted, + hiddenCount, + onRevokeTopSites, + onRevokeBookmarks, + onRestoreHidden, +}: BrowserConnectionsSectionProps): ReactElement | null { + const hasTopSites = topSitesGranted && !!onRevokeTopSites; + const hasBookmarks = bookmarksGranted && !!onRevokeBookmarks; + const hasHidden = hiddenCount > 0; + + if (!hasTopSites && !hasBookmarks && !hasHidden) { + return null; + } + + return ( + <> + +
+
+ + Browser connections + + + Manage what daily.dev can read from your browser. + +
+
    + {hasTopSites && ( + } + label="Most visited sites" + description="Used for auto mode and import." + actionLabel="Disconnect" + onAction={() => onRevokeTopSites?.()} + /> + )} + {hasBookmarks && ( + } + label="Bookmarks bar" + description="Used to import your browser bookmarks." + actionLabel="Disconnect" + onAction={() => onRevokeBookmarks?.()} + /> + )} + {hasHidden && ( + } + label={`Hidden sites (${hiddenCount})`} + description="Sites you removed from auto mode." + actionLabel="Restore all" + onAction={onRestoreHidden} + /> + )} +
+
+ + ); +} + +interface ConnectionRowProps { + icon: ReactElement; + label: string; + description: string; + actionLabel: string; + onAction: () => void; +} + +function ConnectionRow({ + icon, + label, + description, + actionLabel, + onAction, +}: ConnectionRowProps): ReactElement { + return ( +
  • + + {icon} + +
    +

    {label}

    +

    + {description} +

    +
    + +
  • + ); +} 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..487f1197c7f --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useHiddenTopSites.ts @@ -0,0 +1,55 @@ +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 = 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/types.ts b/packages/shared/src/features/shortcuts/types.ts index dbe502668a4..a52344d939a 100644 --- a/packages/shared/src/features/shortcuts/types.ts +++ b/packages/shared/src/features/shortcuts/types.ts @@ -34,5 +34,14 @@ export type ImportSource = 'topSites' | 'bookmarks'; // 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/settings.ts b/packages/shared/src/graphql/settings.ts index bc89f2287c7..86fd312676f 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -1,7 +1,11 @@ import { gql } from 'graphql-request'; import type { SortCommentsBy } from './comments'; import type { WriteFormTab } from '../components/fields/form/common'; -import type { ShortcutMeta, ShortcutsMode } from '../features/shortcuts/types'; +import type { + ShortcutMeta, + ShortcutsAppearance, + ShortcutsMode, +} from '../features/shortcuts/types'; export type Spaciness = 'eco' | 'roomy' | 'cozy'; export type RemoteTheme = 'darcula' | 'bright' | 'auto'; @@ -23,6 +27,7 @@ export type SettingsFlags = { defaultWriteTab?: WriteFormTab; shortcutMeta?: Record; shortcutsMode?: ShortcutsMode; + shortcutsAppearance?: ShortcutsAppearance; }; export enum SidebarSettingsFlags { From f6bd29062f1e7d0f5fd534a1f1d944a36a50e9b3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 17:21:10 +0300 Subject: [PATCH 09/32] fix(shortcuts): align hub dropdown with system DropdownMenu conventions The source-mode toggle row was taller and typographically different from the other items, making the menu look uneven. Rebuild it on the same primitives PostOptionButton uses (h-7, typo-footnote, MenuIcon wrapper) and drop it into the standard DropdownMenuOptions list. Reuse the native Switch with pointer-events-none so clicks fall through to the menu item, keeping menu-open-after-toggle behavior. Remove the custom min-width so the content uses the default DropdownMenuContentAction width. Made-with: Cursor --- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 60 +++++++------------ 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index 65d75bab50f..c5156dce414 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -21,6 +21,7 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; import { DropdownMenu, DropdownMenuContent, @@ -363,9 +364,8 @@ export function ShortcutLinksHub({ aria-label="Shortcut options" /> - + -
    @@ -378,11 +378,13 @@ interface SourceModeToggleItemProps { onToggle: () => void; } -// A stable menu row that flips source mode in place. Lives inside the -// DropdownMenuContent so the surrounding options don't shuffle when the mode -// changes. We call `preventDefault` on `onSelect` so the menu stays open after -// toggling, matching the mental model of "I'm adjusting a setting, not -// triggering an action". +// Stable menu row that flips source mode in place. Uses the same metrics as +// standard DropdownMenuOptions rows (h-7, typo-footnote, MenuIcon wrapper) so +// the dropdown reads as one dense list — matching the PostOptionButton +// convention. The enclosing DropdownMenuItem owns click + keyboard; the +// native Switch is pointer-events-none so clicks fall through to the row +// handler and `preventDefault` on `onSelect` keeps the menu open after +// toggling (it's a setting, not an action). function SourceModeToggleItem({ isAuto, onToggle, @@ -395,40 +397,20 @@ function SourceModeToggleItem({ event.preventDefault(); onToggle(); }} - className="!items-start !gap-3 !py-2.5" > - - + + + Most visited sites + - - - Most visited sites - - - Auto-fill from your browsing history - - - ); } - -// Visual-only switch. The enclosing menu item owns click + keyboard handling. -function SwitchTrack({ checked }: { checked: boolean }): ReactElement { - return ( - - - - ); -} From 0cd654ce98d472996ef027ba38841c337e82b9b9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 17:35:53 +0300 Subject: [PATCH 10/32] refactor(shortcuts): compact modals + realign hub dropdown with settings style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub dropdown - Hide "Add shortcut" in auto mode so the menu carries only relevant rows. - Add a hairline separator below the source-mode toggle to signal it's a setting, not a quick action. Manage modal - Drop the header Import button; move import actions into Browser connections alongside revoke/restore so every browser-sourced concern lives in one place. - Replace the heavy bordered mode cards with lean settings-style radio rows (ring-only selection, quiet hover). - Use Subhead + Caption1 section titles and gap-5 spacing to mirror the Settings page rhythm; remove HorizontalSeparators between sections. - Move "Your shortcuts" list under a proper Subhead and only render it in manual mode since auto mode is browser-populated. Appearance picker - Stronger selected state: cabbage border + corner check badge + bold label (felt like hover before). - Fix illustration shapes: chip becomes rounded-rectangle instead of full pill, tile label becomes rounded-rectangle instead of oval. - Remove long descriptions — titles carry enough meaning. Edit modal - Shrink the title from typo-title3 to Body+bold to match the Manage modal (was dominating the form). - Compact avatar (size-16 + rounded-16), tighter gaps, terser helper copy. Made-with: Cursor --- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 25 +- .../components/modals/ShortcutEditModal.tsx | 49 +- .../modals/ShortcutsManageModal.tsx | 588 +++++++++--------- 3 files changed, 319 insertions(+), 343 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index c5156dce414..b1b0c174a29 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -256,15 +256,18 @@ export function ShortcutLinksHub({ }; const menuOptions = [ - { - icon: , - label: 'Add shortcut', - action: onAdd, - disabled: isAuto, - ariaLabel: isAuto - ? 'Add shortcut (available in My shortcuts mode)' - : 'Add shortcut', - }, + // "Add shortcut" only appears in manual mode. In auto mode the row is + // populated from browser history, so surfacing a disabled add item would + // be noise. The toggle at the top handles the mode switch. + ...(isAuto + ? [] + : [ + { + icon: , + label: 'Add shortcut', + action: onAdd, + }, + ]), { icon: , label: 'Manage shortcuts…', @@ -366,6 +369,10 @@ export function ShortcutLinksHub({ +
    diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index d2df0c116a2..875381a38fc 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -6,8 +6,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; import classNames from 'classnames'; import { Button, + ButtonSize, ButtonVariant, } from '../../../../components/buttons/Button'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; import ControlledTextField from '../../../../components/fields/ControlledTextField'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; @@ -163,19 +169,20 @@ export default function ShortcutEditModal({ return ( - - + {/* Title uses the same Body+bold rhythm as the Manage modal so the two + surfaces feel like siblings, not different products. */} + + {mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} - +
    {/* Icon-first: a single tappable avatar at the top. The favicon derived from the URL fills it by default; uploading swaps it - out. No secondary preview tile — users don't need to see the - shortcut re-rendered to know what it'll look like. */} -
    + out. */} +
    ) : ( - {faviconSrc - ? 'Using site favicon. Click the avatar to upload your own.' - : 'Click the avatar to upload an icon.'} + {faviconSrc ? 'Tap to upload your own' : 'Tap to upload'} )}
    -
    +
    + - ); })} @@ -435,30 +409,6 @@ export default function ShortcutsManageModal( const onAdd = () => openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); - // Labels lead with the source ("Most visited sites", "Bookmarks bar") and - // include counts when the browser has already handed them over. If we - // haven't checked permission yet, the label invites the user to grant it - // rather than pretending we know the count. - const topSitesLabel = topSitesKnown - ? `Most visited sites · ${topSitesCount} available` - : 'Most visited sites · grant access to preview'; - const bookmarksLabel = bookmarksKnown - ? `Bookmarks bar · ${bookmarksCount} available` - : 'Bookmarks bar · grant access to preview'; - - const importOptions = [ - { - icon: , - label: topSitesLabel, - action: () => setShowImportSource?.('topSites'), - }, - { - icon: , - label: bookmarksLabel, - action: () => setShowImportSource?.('bookmarks'), - }, - ]; - return ( @@ -473,83 +423,64 @@ export default function ShortcutsManageModal( {manager.shortcuts.length}/{MAX_SHORTCUTS}
    -
    - - - - - - - - - -
    + -
    + {/* Matches the settings page rhythm: sections spaced with gap, bold + Subhead titles, no heavy separators between groups. */} +
    -
    - +
    + Show shortcuts - Toggle the shortcut row visibility on the new-tab page. + Toggle the row visibility on the new-tab page.
    - {showTopSites ? 'On' : 'Off'} - + aria-label="Show shortcuts" + />
    {showTopSites && ( <> - -
    - Shortcuts source +
    + + Source + selectMode('manual')} title="My shortcuts" - description="Shortcuts are curated by you — add, edit, remove, and reorder them." + description="Curated by you — add, edit, reorder." /> selectMode('auto')} title="Most visited sites" - description="Shortcuts are suggested based on websites you visit often." + description="Suggested from your browser history." />
    - - )} - - - {manager.shortcuts.length === 0 ? ( -
    - - - -
    - - No shortcuts yet + {mode === 'manual' && ( +
    +
    + + Your shortcuts - Add your first shortcut or import from your browser. + {manager.shortcuts.length}/{MAX_SHORTCUTS}
    -
    - - - -
    -
    - ) : ( -
    -
    - - - s.url)} - strategy={verticalListSortingStrategy} - > - {manager.shortcuts.map((shortcut) => ( - - ))} - - -
    + ) : ( +
    + + + s.url)} + strategy={verticalListSortingStrategy} + > + {manager.shortcuts.map((shortcut) => ( + + ))} + + +
    + )} + )} setShowImportSource('topSites') + : undefined + } + onImportBookmarks={ + setShowImportSource + ? () => setShowImportSource('bookmarks') + : undefined + } + onAskTopSites={askTopSitesPermission} onRevokeTopSites={onRevokePermission} onRevokeBookmarks={revokeBookmarksPermission} onRestoreHidden={() => restoreHiddenTopSites()} @@ -672,80 +600,101 @@ interface BrowserConnectionsSectionProps { topSitesGranted: boolean; bookmarksGranted: boolean; hiddenCount: number; + topSitesCount: number; + bookmarksCount: number; + topSitesKnown: boolean; + bookmarksKnown: boolean; + onImportTopSites?: () => void; + onImportBookmarks?: () => void; + onAskTopSites?: () => void | Promise; onRevokeTopSites?: () => void | Promise; onRevokeBookmarks?: () => void | Promise; onRestoreHidden: () => void; } +// Single home for anything that involves the browser: +// import (primary action), revoke (secondary), and restore hidden. +// Lives at the bottom because it's a "settings-like" section — less used +// than adding/editing shortcuts but too important to bury in a menu. function BrowserConnectionsSection({ topSitesGranted, bookmarksGranted, hiddenCount, + topSitesCount, + bookmarksCount, + topSitesKnown, + bookmarksKnown, + onImportTopSites, + onImportBookmarks, + onAskTopSites, onRevokeTopSites, onRevokeBookmarks, onRestoreHidden, -}: BrowserConnectionsSectionProps): ReactElement | null { - const hasTopSites = topSitesGranted && !!onRevokeTopSites; - const hasBookmarks = bookmarksGranted && !!onRevokeBookmarks; - const hasHidden = hiddenCount > 0; - - if (!hasTopSites && !hasBookmarks && !hasHidden) { - return null; - } - +}: BrowserConnectionsSectionProps): ReactElement { return ( - <> - -
    -
    - - Browser connections - - - Manage what daily.dev can read from your browser. - -
    -
      - {hasTopSites && ( - } - label="Most visited sites" - description="Used for auto mode and import." - actionLabel="Disconnect" - onAction={() => onRevokeTopSites?.()} - /> - )} - {hasBookmarks && ( - } - label="Bookmarks bar" - description="Used to import your browser bookmarks." - actionLabel="Disconnect" - onAction={() => onRevokeBookmarks?.()} - /> - )} - {hasHidden && ( - } - label={`Hidden sites (${hiddenCount})`} - description="Sites you removed from auto mode." - actionLabel="Restore all" - onAction={onRestoreHidden} - /> - )} -
    -
    - +
    +
    + + Browser connections + + + Import from and manage what daily.dev can read from your browser. + +
    +
      + } + label="Most visited sites" + description={ + topSitesKnown + ? `${topSitesCount} available` + : 'Grant access to import or switch to auto mode.' + } + primaryLabel={topSitesGranted ? 'Import' : 'Connect'} + onPrimary={ + topSitesGranted + ? onImportTopSites + : onAskTopSites + ? () => onAskTopSites() + : undefined + } + secondaryLabel={topSitesGranted ? 'Disconnect' : undefined} + onSecondary={ + topSitesGranted ? () => onRevokeTopSites?.() : undefined + } + /> + } + label="Bookmarks bar" + description={ + bookmarksKnown + ? `${bookmarksCount} available` + : 'Grant access to import your browser bookmarks.' + } + primaryLabel={bookmarksGranted ? 'Import' : 'Connect'} + onPrimary={bookmarksGranted ? onImportBookmarks : onImportBookmarks} + secondaryLabel={bookmarksGranted ? 'Disconnect' : undefined} + onSecondary={ + bookmarksGranted ? () => onRevokeBookmarks?.() : undefined + } + /> + {hiddenCount > 0 && ( + } + label={`Hidden sites (${hiddenCount})`} + description="Sites you removed from auto mode." + primaryLabel="Restore all" + onPrimary={onRestoreHidden} + /> + )} +
    +
    ); } @@ -753,16 +702,20 @@ interface ConnectionRowProps { icon: ReactElement; label: string; description: string; - actionLabel: string; - onAction: () => void; + primaryLabel: string; + onPrimary?: () => void; + secondaryLabel?: string; + onSecondary?: () => void; } function ConnectionRow({ icon, label, description, - actionLabel, - onAction, + primaryLabel, + onPrimary, + secondaryLabel, + onSecondary, }: ConnectionRowProps): ReactElement { return (
  • @@ -775,14 +728,27 @@ function ConnectionRow({ {description}

  • - +
    + {secondaryLabel && ( + + )} + +
    ); } From 66ddda933ffc163ca668185fb5dea86428f72763 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 10:14:29 +0300 Subject: [PATCH 11/32] feat(shortcuts): polish modals, icons, and states across the hub - Anchor every ShortcutsManageModal section with a tinted icon chip and hairline dividers so it reads as distinct cards of config. - Capacity badge next to "Your shortcuts" warms (cabbage) as the library fills and flips to ketchup at the cap; empty state gets a proper illustrated CTA. - Appearance cards light up when selected (accent border + soft tint) and lift subtly on hover; destructive row hover tints in ketchup. - ShortcutEditModal: drop-to-upload on the avatar, real spinner ring during upload, live 40-char name counter, and cabbage-accented drag state. - ImportPickerModal: source-aware empty state with icon + copy, selected rows get an accent leading bar + cabbage tint, checkmark pops on select, Select/Clear all is a filled pill. - Revert right-click-to-open menu on ShortcutTile (native context menu restored); hub-level right-click on toolbar background still opens the overflow menu. Made-with: Cursor --- .../ShortcutLinks/ShortcutGetStarted.tsx | 134 ++++- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 91 +++- .../shared/src/components/MainFeedLayout.tsx | 4 + .../shared/src/components/modals/common.tsx | 8 - .../src/components/modals/common/types.ts | 1 - .../shortcuts/components/AddShortcutTile.tsx | 117 ++++- .../shortcuts/components/ShortcutTile.tsx | 6 +- .../components/WebappShortcutsRow.tsx | 212 ++++++++ .../modals/BookmarksPermissionModal.tsx | 61 --- .../components/modals/ImportPickerModal.tsx | 191 +++++--- .../modals/MostVisitedSitesModal.tsx | 17 +- .../components/modals/ShortcutEditModal.tsx | 239 ++++++--- .../modals/ShortcutsManageModal.tsx | 460 +++++++++++++----- .../shortcuts/hooks/useShortcutsMigration.ts | 41 +- .../features/shortcuts/hooks/useTopSites.ts | 3 +- packages/shared/src/graphql/settings.ts | 1 + packages/shared/src/lib/log.ts | 3 + packages/webapp/pages/_app.tsx | 5 +- 18 files changed, 1204 insertions(+), 390 deletions(-) create mode 100644 packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx delete mode 100644 packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx index acf7977335f..bbc1e96e043 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx @@ -18,6 +18,58 @@ import type { PropsWithChildren, ReactElement } from 'react'; import React from 'react'; import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils'; import { useActions } from '@dailydotdev/shared/src/hooks'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; + +// Curated "dev starter pack" that drives the empty-state suggestions. Order +// matches the illustration so what the user sees is exactly what gets +// seeded when they click "Quick pick". Names are kept short so they fit +// the tile labels the moment they're added. +interface SuggestedSite { + url: string; + name: string; + icon: string; +} + +function buildSuggestions(githubIcon: string): SuggestedSite[] { + return [ + { url: 'https://mail.google.com', name: 'Gmail', icon: cloudinaryShortcutsIconsGmail }, + { url: 'https://github.com', name: 'GitHub', icon: githubIcon }, + { url: 'https://reddit.com', name: 'Reddit', icon: cloudinaryShortcutsIconsReddit }, + { url: 'https://chatgpt.com', name: 'ChatGPT', icon: cloudinaryShortcutsIconsOpenai }, + { url: 'https://stackoverflow.com', name: 'Stack Overflow', icon: cloudinaryShortcutsIconsStackoverflow }, + ]; +} + +function SuggestedSiteButton({ + site, + onAdd, +}: { + site: SuggestedSite; + onAdd: (site: SuggestedSite) => void; +}): ReactElement { + return ( + + ); +} function ShortcutItemPlaceholder({ children }: PropsWithChildren) { return ( @@ -43,24 +95,55 @@ export const ShortcutGetStarted = ({ }: ShortcutGetStartedProps): ReactElement => { const { githubShortcut } = useThemedAsset(); const { completeAction, checkHasCompleted } = useActions(); + const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); - const items = [ - cloudinaryShortcutsIconsGmail, - githubShortcut, - cloudinaryShortcutsIconsReddit, - cloudinaryShortcutsIconsOpenai, - cloudinaryShortcutsIconsStackoverflow, - ]; + const suggestions = buildSuggestions(githubShortcut); - const completeActionThenFire = (callback?: () => void) => { + const markStarted = () => { if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { completeAction(ActionType.FirstShortcutsSession); } + }; + + const completeActionThenFire = (callback?: () => void) => { + markStarted(); callback?.(); }; + // Add a single suggested site without leaving the empty state. Duplicate + // detection lives in the manager, so a user who somehow already has the + // URL (e.g. imported earlier) gets a clear toast instead of a silent no-op. + const addSuggestion = async (site: SuggestedSite) => { + const result = await manager.addShortcut({ + url: site.url, + name: site.name, + }); + if (result.error) { + displayToast(result.error); + return; + } + markStarted(); + }; + + // "Quick pick" seeds the whole starter pack in one shot. Uses the same + // importFrom path that the browser-bookmarks importer uses so dedupe and + // capacity handling are consistent. + const addAllSuggestions = async () => { + const result = await manager.importFrom( + 'topSites', + suggestions.map((s) => ({ url: s.url, title: s.name })), + ); + if (result.imported === 0) { + displayToast('All these shortcuts already exist.'); + return; + } + markStarted(); + displayToast(`Added ${result.imported} shortcut${result.imported === 1 ? '' : 's'}.`); + }; + return ( -
    +
    @@ -68,20 +151,21 @@ export const ShortcutGetStarted = ({ Choose your most visited sites

    - Pin the sites you hit every day. Add your own, or import from your - browser in a click. + Pin the sites you hit every day. Tap a suggestion below for a + one-click start — or add your own.

    -
    - {items.map((url) => ( - - {`Icon - +
    + {suggestions.map((site) => ( + ))} @@ -105,6 +189,14 @@ export const ShortcutGetStarted = ({ Import )} + @@ -77,15 +177,16 @@ export function AddShortcutTile({ type="button" onClick={onClick} disabled={disabled} + {...dropProps} className={classNames( 'group flex w-[76px] flex-col items-center gap-1.5 rounded-14 p-2 outline-none transition-colors duration-150 ease-out hover:bg-surface-float focus-visible:bg-surface-float motion-reduce:transition-none', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent', )} - aria-label="Add shortcut" + aria-label={`Add shortcut${dropHint}`} > {iconBox} - Add + {isDropTarget ? 'Drop to add' : 'Add'} ); diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index 29d2ff57111..fae65d2a546 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -341,7 +341,11 @@ export function ShortcutTile({ - + {/* 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..93c4acc125e --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -0,0 +1,212 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, 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 { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { LazyModal } from '../../../components/modals/common/types'; +import { + LogEvent, + ShortcutsSourceType, + TargetType, +} from '../../../lib/log'; +import { ShortcutTile } from './ShortcutTile'; +import { AddShortcutTile } from './AddShortcutTile'; +import { useShortcutsManager } from '../hooks/useShortcutsManager'; +import { + DEFAULT_SHORTCUTS_APPEARANCE, + MAX_SHORTCUTS, +} from '../types'; +import type { + Shortcut, + ShortcutsAppearance, +} from '../types'; + +interface WebappShortcutsRowProps { + className?: string; +} + +/** + * Webapp-side shortcut row. Only renders when the user has enabled + * `showShortcutsOnWebapp` from the extension's manage modal. Reuses the same + * `ShortcutTile` and `useShortcutsManager` the extension hub does so edits + * and reorders stay in sync across surfaces. + * + * Auto mode (live top-sites from the browser) is intentionally ignored on + * the webapp — we don't have topSites permission outside the extension and + * the "most visited sites" concept doesn't travel across devices anyway. + * Manual curated shortcuts do. + */ +export function WebappShortcutsRow({ + className, +}: WebappShortcutsRowProps): ReactElement | null { + const { flags, showTopSites } = useSettingsContext(); + const { openModal } = useLazyModal(); + const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); + const manager = useShortcutsManager(); + + const enabled = flags?.showShortcutsOnWebapp ?? false; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + + const shortcuts: Shortcut[] = useMemo( + () => manager.shortcuts.slice(0, MAX_SHORTCUTS), + [manager.shortcuts], + ); + + // One-shot impression per enabled->rendered cycle. Lets us slice hub + // adoption between "on the extension" and "on the webapp" without needing + // client-side duplication. + 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: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // Same click-suppression guard the extension hub uses: dnd-kit swallows + // the pointerdown→up sequence but the browser still fires a click on + // release, so we have to intercept or the link would navigate mid-drag. + const justDraggedRef = useRef(false); + const armDragSuppression = () => { + justDraggedRef.current = true; + }; + const suppressClickCapture = (event: React.MouseEvent) => { + if (!justDraggedRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + justDraggedRef.current = false; + }; + + const handleDragEnd = (event: DragEndEvent) => { + armDragSuppression(); + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const urls = shortcuts.map((s) => s.url); + const oldIndex = urls.indexOf(active.id as string); + const newIndex = urls.indexOf(over.id as string); + if (oldIndex < 0 || newIndex < 0) { + return; + } + const overflowUrls = manager.shortcuts + .slice(MAX_SHORTCUTS) + .map((s) => s.url); + manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); + }; + + const onEdit = (shortcut: Shortcut) => + openModal({ + type: LazyModal.ShortcutEdit, + props: { mode: 'edit', shortcut }, + }); + + const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + + const onAdd = () => + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + + const onDropUrl = async (url: string) => { + const result = await manager.addShortcut({ url }); + if (result.error) { + displayToast(result.error); + } + }; + + // Gatekeeping: only render for opted-in users with something to show or + // the ability to add. Users who haven't turned on the setting — or who + // hid the row entirely — get nothing. + if (!enabled || !showTopSites) { + return null; + } + if (shortcuts.length === 0 && !manager.canAdd) { + return null; + } + + return ( +
    + + s.url)} + strategy={horizontalListSortingStrategy} + > + {shortcuts.map((shortcut) => ( + + ))} + + + {manager.canAdd && ( + + )} +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx b/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx deleted file mode 100644 index 505eb67ffef..00000000000 --- a/packages/shared/src/features/shortcuts/components/modals/BookmarksPermissionModal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -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 { useShortcuts } from '../../contexts/ShortcutsProvider'; -import { useShortcutsManager } from '../../hooks/useShortcutsManager'; - -export default function BookmarksPermissionModal({ - ...props -}: ModalProps): ReactElement { - const { askBookmarksPermission, bookmarks, setShowImportSource } = - useShortcuts(); - const manager = useShortcutsManager({ bookmarks }); - - const handleGrant = async () => { - const granted = await askBookmarksPermission(); - if (!granted) { - return; - } - // After permission granted we can't always trust `bookmarks` is populated - // synchronously. Delay one tick and import whatever we have. - setTimeout(async () => { - await manager.importFrom( - 'bookmarks', - (bookmarks ?? []).map((b) => ({ url: b.url, title: b.title })), - ); - setShowImportSource?.(null); - props.onRequestClose?.(undefined as never); - }, 0); - }; - - const onRequestClose = () => { - setShowImportSource?.(null); - props.onRequestClose?.(undefined as never); - }; - - 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. - - - - - - - ); -} diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx index 324eefa21c2..e41a0af3af2 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -5,7 +5,13 @@ 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 { VIcon } from '../../../../components/icons'; +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'; @@ -72,7 +78,8 @@ export default function ImportPickerModal({ const manager = useShortcutsManager(); const { displayToast } = useToastNotification(); - const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); + const alreadyUsed = customLinks?.length ?? 0; + const capacity = Math.max(0, MAX_SHORTCUTS - alreadyUsed); const [checked, setChecked] = useState>(() => { const state: Record = {}; items.slice(0, capacity).forEach((item) => { @@ -125,72 +132,114 @@ export default function ImportPickerModal({ const isBookmarks = source === 'bookmarks'; const title = isBookmarks ? 'Import bookmarks' : 'Import most visited'; - const subtitle = isBookmarks - ? 'Tap to pick. Your bookmarks stay untouched.' - : 'Tap to pick. Added as a snapshot of your history.'; + // Spell out where the list came from and how many rows the browser surfaced. + // Stops users assuming we've clipped the list at whatever number they see + // (Chrome's topSites API, for instance, returns however many repeat-visit + // origins the profile has — sometimes 8, sometimes 20). + const sourceCopy = isBookmarks + ? `Tap to pick. Your bookmarks stay untouched — ${items.length} found.` + : `Tap to pick. Snapshot from your browser — ${items.length} site${ + items.length === 1 ? '' : 's' + } shared.`; - // Segmented capacity meter. Rather than a thin progress line that reads as - // a random pink scratch, we render one pip per slot. Filled pips are your - // picks; empty pips are the room you still have. Lights up like a battery. - const pips = Array.from({ length: Math.max(capacity, 1) }); + // Capacity meter always represents the full library (MAX_SHORTCUTS slots), + // so users can see at a glance that the limit is 12 — not whatever number + // is left after their existing shortcuts. Three zones stack across it: + // already saved (muted), currently picking (accent), free (empty). + const pips = Array.from({ length: MAX_SHORTCUTS }); + const filledEnd = alreadyUsed + selected.length; return ( - -
    - {title} -

    - {subtitle} -

    -
    + {/* Same header rhythm as the Manage / Edit modals: left-aligned, Body + bold, no oversized Title1. Subtitle lives in the body as helper + copy so the header stays compact. */} + + + {title} + +

    {sourceCopy}

    {/* Capacity bar: count + pip row + inline select-all toggle. Three affordances packed into one compact strip so the body can breathe. */} -
    +
    +
    +
    + + {filledEnd} + /{MAX_SHORTCUTS} + + + {alreadyUsed > 0 + ? `${alreadyUsed} saved · ${selected.length} picked` + : `${selected.length} picked`} + +
    + +
    - - {selected.length} - /{capacity} - -
    - {pips.map((_, idx) => ( + {pips.map((_, idx) => { + const inSaved = idx < alreadyUsed; + const inPicked = !inSaved && idx < filledEnd; + return ( - ))} -
    + ); + })}
    -
    {items.length === 0 ? ( -
    - - {isBookmarks - ? 'Your bookmarks bar is empty.' - : 'No most visited sites yet.'} + // Empty state worth looking at. The source-specific glyph tells + // users what we tried to read from, and the copy explains *why* + // there's nothing — not just "empty" which reads like our bug. +
    + + {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.'} +
    ) : ( // Tap-to-toggle rows. No separate checkbox column. Selected state is @@ -214,28 +263,24 @@ export default function ImportPickerModal({ disabled={atCap} onClick={() => toggle(item.url)} className={classNames( - 'group flex w-full items-center gap-3 rounded-12 p-2 text-left transition-colors duration-150 motion-reduce:transition-none', + // Quiet-by-default row that picks up a subtle cabbage + // tint + thin accent bar on the leading edge when + // selected, so a full page of rows reads as "these + // are picked" instantly without shouting. + 'group relative flex w-full items-center gap-3 rounded-12 p-2 text-left transition-all duration-150 active:scale-[0.995] motion-reduce:transform-none motion-reduce:transition-none', isChecked - ? 'bg-surface-float' + ? 'bg-overlay-float-cabbage/50' : 'hover:bg-surface-float', atCap && 'cursor-not-allowed opacity-40', )} > - - - {/* Selection badge overlays the icon bottom-right. - Scales in when picked for a light touch of delight - without the whole row having to slide or shift. */} + {isChecked && ( - - - + className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-accent-cabbage-default" + /> + )} +

    {label} @@ -244,6 +289,28 @@ export default function ImportPickerModal({ {getDomainFromUrl(item.url)}

    + {/* Selection indicator on the trailing edge — reads as + "this row is picked" the moment you glance at it, + instead of squinting at a tiny badge tucked behind + the favicon. Empty ring at rest gives the row a + clear "tap me" affordance. */} + + + ); @@ -254,10 +321,10 @@ export default function ImportPickerModal({ {atCapacity - ? 'Capacity reached' - : `${Math.max(0, capacity - selected.length)} slot${ + ? `Library full (${MAX_SHORTCUTS}/${MAX_SHORTCUTS})` + : `${Math.max(0, capacity - selected.length)} of ${MAX_SHORTCUTS} slot${ capacity - selected.length === 1 ? '' : 's' - } left`} + } free`}
    + {/* All icon-related affordances live with the avatar: + upload status, remove, and the "paste URL" escape hatch. + Keeping them together means a user scanning the form + doesn't have to hunt around for icon controls. */}
    {isUploading ? ( - - + + Uploading… - ) : hasCustomIcon ? ( - - ) : ( - - {faviconSrc ? 'Tap to upload your own' : 'Tap to upload'} + ) : isDropTarget ? ( + + Drop to use this image + ) : ( + <> + {hasCustomIcon ? ( + + ) : customIconFailed ? ( + + Couldn't load that image — showing favicon instead + + ) : ( + + {faviconSrc + ? 'Tap or drop to upload' + : 'Tap or drop an image to upload'} + + )} + + · + + + )}
    + {showUrlInput && ( +
    + +
    + )}
    +
    + +
    + {nameHint} +
    +
    - - - {showUrlInput && ( - - )}
    @@ -292,7 +417,7 @@ export default function ShortcutEditModal({ type="button" variant={ButtonVariant.Float} size={ButtonSize.Small} - onClick={() => props.onRequestClose?.(undefined as never)} + onClick={close} > Cancel diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 64b431fad49..b0b0138d906 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -36,10 +36,16 @@ import { Switch } from '../../../../components/fields/Switch'; import { BookmarkIcon, DragIcon, + EarthIcon, EditIcon, + EyeIcon, + LayoutIcon, + LinkIcon, + MagicIcon, PlusIcon, RefreshIcon, SitesIcon, + StarIcon, TrashIcon, VIcon, } from '../../../../components/icons'; @@ -56,9 +62,84 @@ import { getDomainFromUrl } from '../../../../lib/links'; import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../../types'; import type { Shortcut, ShortcutsAppearance } from '../../types'; -// Lean mode row styled like the settings-page radio pattern: -// borderless by default, a quiet hover, and a filled cabbage ring on select. -// No left accent rail, no heavy outline — the radio dot carries the state. +// Reusable section header that anchors every top-level group in the modal. +// The small glyph chip on the left gives each section a unique "family crest" +// so users can scan the modal vertically and know where they are at a glance +// (Apple System Settings / Raycast pattern). The chip stays neutral by +// default and picks up a subtle accent tint when the section is the active +// subject — we use that only on Appearance right now but the API is ready. +function SectionHeader({ + icon, + title, + description, + trailing, +}: { + icon: ReactElement; + title: string; + description?: string; + trailing?: ReactElement; +}): ReactElement { + return ( +
    + + {icon} + +
    + + {title} + + {description && ( + + {description} + + )} +
    + {trailing} +
    + ); +} + +// Compact capacity pill used next to "Your shortcuts". The tone warms up as +// the library fills so the limit feels present without ever shouting — grey +// through most of the range, cabbage accent when there are two or fewer +// slots left, rose when the cap is hit. Tabular nums keep the width steady +// as the count ticks up. +function CapacityPill({ + used, + max, +}: { + used: number; + max: number; +}): ReactElement { + const remaining = max - used; + const tone = + used >= max + ? 'bg-overlay-float-ketchup text-accent-ketchup-default' + : remaining <= 2 + ? 'bg-overlay-float-cabbage text-accent-cabbage-default' + : 'bg-surface-float text-text-tertiary'; + return ( + + {used}/{max} + + ); +} + +// Clean radio row. Selected state is carried entirely by the filled cabbage +// dot + bold title — no background fill, so it never reads like a hover. +// Hover is the only place we tint the surface, which keeps the difference +// between "you're pointing at this" and "this is selected" obvious. function ShortcutsModeOption({ id, checked, @@ -75,10 +156,7 @@ function ShortcutsModeOption({ return ( @@ -167,7 +254,10 @@ function ShortcutRow({ {shortcut.url}

    -
    + {/* Actions fade in on row hover/focus. On touch devices (no hover), + we reveal them at 60% opacity so they're always reachable without + overwhelming the row. */} +
    @@ -255,8 +345,12 @@ function AppearancePicker({ return (
    - - Appearance + + } + title="Appearance" + description="How the row renders on the new-tab page." + />
    onChange(opt.id)} className={classNames( - 'group relative flex flex-col items-center gap-1.5 rounded-10 border p-2 text-left outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-2 focus-visible:ring-offset-background-default motion-reduce:transition-none', + // Card rests on a 1px border. Selected adds an accent border + // + corner badge + a soft cabbage-tinted background to the + // preview shelf, so the choice feels lit up, not merely + // outlined. Focus ring stays on the whole card for keyboards. + 'group relative flex flex-col items-center gap-1.5 rounded-12 border p-2 text-left outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-2 focus-visible:ring-offset-background-default motion-reduce:transition-none', checked - ? 'border-accent-cabbage-default bg-surface-float' - : 'border-border-subtlest-tertiary hover:border-border-subtlest-secondary', + ? 'border-accent-cabbage-default bg-overlay-float-cabbage/40' + : 'border-border-subtlest-tertiary hover:-translate-y-px hover:border-border-subtlest-secondary hover:bg-surface-float', )} > - {/* A small corner badge is the clearest "this one is chosen" - signal — stronger than a color swap but quieter than an - accent rail that covers the whole row. */} {checked && ( )} -
    +
    {opt.preview}
    { + closeModal(); + props?.onRequestClose?.(undefined as never); + }; const mode = flags?.shortcutsMode ?? 'manual'; const selectMode = async (next: 'manual' | 'auto') => { @@ -344,6 +449,11 @@ export default function ShortcutsManageModal( return; } await updateFlag('shortcutsMode', next); + logEvent({ + event_name: LogEvent.ChangeShortcutsMode, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ mode: next }), + }); if (next === 'auto' && topSites === undefined) { await askTopSitesPermission(); } @@ -356,6 +466,26 @@ export default function ShortcutsManageModal( return; } updateFlag('shortcutsAppearance', next); + logEvent({ + event_name: LogEvent.ChangeShortcutsAppearance, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ appearance: next }), + }); + }; + + // Sync flag: when on, the same shortcuts render on daily.dev's web app + // (not just the new-tab extension). Lives here in the manage modal — with + // a clear description — instead of as a one-line toggle in the dropdown + // where the "what does this do" wasn't obvious. + const showOnWebapp = flags?.showShortcutsOnWebapp ?? false; + const toggleShowOnWebapp = () => { + const next = !showOnWebapp; + updateFlag('showShortcutsOnWebapp', next); + logEvent({ + event_name: LogEvent.ToggleShortcutsOnWebapp, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ enabled: next }), + }); }; const topSitesCount = topSites?.length ?? 0; @@ -411,60 +541,66 @@ export default function ShortcutsManageModal( return ( + {/* Header: title only on the left, primary Done on the right. The + count badge moved out of the header — it lives next to the + "Your shortcuts" subhead where it's contextual instead of + floating above unrelated sections. */} -
    - - Shortcuts - - - {manager.shortcuts.length}/{MAX_SHORTCUTS} - -
    + + Shortcuts +
    - {/* Matches the settings page rhythm: sections spaced with gap, bold - Subhead titles, no heavy separators between groups. */} -
    -
    -
    - - Show shortcuts - - - Toggle the row visibility on the new-tab page. - + {/* Settings flow, top to bottom: visibility → look → source → list → + connections. Each section gets a small anchor glyph via + SectionHeader so the modal reads as a set of distinct "cards" of + configuration rather than a wall of bolded titles, and we drop + hairline dividers between them for vertical rhythm. */} +
    + } + title="Show shortcuts" + description="Toggle the row visibility on the new-tab page." + trailing={ + + } + /> + + {showTopSites && ( +
    +
    - -
    + )} {showTopSites && ( - <> -
    - - Source - +
    + + } + title="Source" + description="Choose where this row gets its shortcuts from." + /> + +
    -
    - - - +
    +
    )} {mode === 'manual' && ( -
    -
    - - Your shortcuts - - - {manager.shortcuts.length}/{MAX_SHORTCUTS} - -
    +
    + } + title="Your shortcuts" + description="Drag to reorder. Hover a row to edit or remove." + trailing={ + + } + /> {manager.shortcuts.length === 0 ? ( -
    +
    + + + - No shortcuts yet + Your shortcuts, your rules - Add one manually or import from Browser connections below. + Add one manually or import from Connections below. setShowImportSource('topSites') @@ -586,6 +737,7 @@ export default function ShortcutsManageModal( : undefined } onAskTopSites={askTopSitesPermission} + onAskBookmarks={askBookmarksPermission} onRevokeTopSites={onRevokePermission} onRevokeBookmarks={revokeBookmarksPermission} onRestoreHidden={() => restoreHiddenTopSites()} @@ -600,53 +752,55 @@ interface BrowserConnectionsSectionProps { topSitesGranted: boolean; bookmarksGranted: boolean; hiddenCount: number; + isAuto: boolean; topSitesCount: number; bookmarksCount: number; topSitesKnown: boolean; bookmarksKnown: boolean; + showOnWebapp: boolean; + onToggleShowOnWebapp: () => void; onImportTopSites?: () => void; onImportBookmarks?: () => void; onAskTopSites?: () => void | Promise; + onAskBookmarks?: () => void | Promise; onRevokeTopSites?: () => void | Promise; onRevokeBookmarks?: () => void | Promise; onRestoreHidden: () => void; } -// Single home for anything that involves the browser: -// import (primary action), revoke (secondary), and restore hidden. -// Lives at the bottom because it's a "settings-like" section — less used -// than adding/editing shortcuts but too important to bury in a menu. +// Groups every cross-surface concern: permissions the browser grants us to +// read (top sites, bookmarks, hidden restoration) AND where we write the +// shortcuts (just this new-tab page, or synced to daily.dev). Previously the +// "Show on daily.dev" setting floated between Source and Your shortcuts as +// its own loose card, which fought the rest of the modal's rhythm. Living +// here, it reads as one more connection — just one that flows outward +// instead of inward. function BrowserConnectionsSection({ topSitesGranted, bookmarksGranted, hiddenCount, + isAuto, topSitesCount, bookmarksCount, topSitesKnown, bookmarksKnown, + showOnWebapp, + onToggleShowOnWebapp, onImportTopSites, onImportBookmarks, onAskTopSites, + onAskBookmarks, onRevokeTopSites, onRevokeBookmarks, onRestoreHidden, }: BrowserConnectionsSectionProps): ReactElement { return ( -
    -
    - - Browser connections - - - Import from and manage what daily.dev can read from your browser. - -
    +
    + } + title="Connections" + description="Import from your browser, or sync this row to daily.dev so it follows you across signed-in devices." + />
      } @@ -669,6 +823,20 @@ function BrowserConnectionsSection({ topSitesGranted ? () => onRevokeTopSites?.() : undefined } /> + {/* Hidden sites is purely an auto-mode concept: the only way to add + to this list is to X-out a tile in the live top-sites row. In + manual mode it's dead data, so we hide it. Pinning it directly + under Most visited sites (rather than after Bookmarks) makes the + ownership obvious at a glance — "these go together". */} + {isAuto && hiddenCount > 0 && ( + } + label={`Hidden sites (${hiddenCount})`} + description="Restore sites you removed from your Most visited row." + primaryLabel="Restore all" + onPrimary={onRestoreHidden} + /> + )} } label="Bookmarks bar" @@ -678,21 +846,33 @@ function BrowserConnectionsSection({ : 'Grant access to import your browser bookmarks.' } primaryLabel={bookmarksGranted ? 'Import' : 'Connect'} - onPrimary={bookmarksGranted ? onImportBookmarks : onImportBookmarks} + onPrimary={ + bookmarksGranted + ? onImportBookmarks + : onAskBookmarks + ? () => onAskBookmarks() + : undefined + } secondaryLabel={bookmarksGranted ? 'Disconnect' : undefined} onSecondary={ bookmarksGranted ? () => onRevokeBookmarks?.() : undefined } /> - {hiddenCount > 0 && ( - } - label={`Hidden sites (${hiddenCount})`} - description="Sites you removed from auto mode." - primaryLabel="Restore all" - onPrimary={onRestoreHidden} - /> - )} + } + label="Sync to daily.dev" + description="Show these shortcuts on the web app on every signed-in browser." + trailing={ + + } + />
    ); @@ -702,10 +882,15 @@ interface ConnectionRowProps { icon: ReactElement; label: string; description: string; - primaryLabel: string; + primaryLabel?: string; onPrimary?: () => void; secondaryLabel?: string; onSecondary?: () => void; + // Optional override for the trailing control. When provided, we skip the + // primary/secondary button pair and render this slot instead. Lets the + // sync row drop a Switch into the same footprint without a special-case + // component. + trailing?: ReactElement; } function ConnectionRow({ @@ -716,6 +901,7 @@ function ConnectionRow({ onPrimary, secondaryLabel, onSecondary, + trailing, }: ConnectionRowProps): ReactElement { return (
  • @@ -729,25 +915,31 @@ function ConnectionRow({

  • - {secondaryLabel && ( - + {trailing ?? ( + <> + {secondaryLabel && ( + + )} + {primaryLabel && ( + + )} + )} -
    ); diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts index 65f486a4807..604272bfe27 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts @@ -10,6 +10,15 @@ 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 } = useSettingsContext(); @@ -19,16 +28,18 @@ export const useShortcutsMigration = (): void => { topSitesUrls: topSites?.map((s) => s.url), }); const { displayToast } = useToastNotification(); + const inFlightRef = useRef(false); const ranRef = useRef(false); useEffect(() => { - if (ranRef.current) { + if (ranRef.current || inFlightRef.current) { return; } if (!isActionsFetched || !hasCheckedPermission) { return; } if (checkHasCompleted(ActionType.ShortcutsMigratedFromTopSites)) { + ranRef.current = true; return; } if ((customLinks?.length ?? 0) > 0) { @@ -38,16 +49,26 @@ export const useShortcutsMigration = (): void => { return; } - ranRef.current = true; + inFlightRef.current = true; const items = topSites.map((s) => ({ url: s.url })); - manager.importFrom('topSites', items).then((result) => { - if (result.imported > 0) { - displayToast( - 'We imported your most visited sites. You can edit them anytime.', - ); - } - completeAction(ActionType.ShortcutsMigratedFromTopSites); - }); + manager + .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, diff --git a/packages/shared/src/features/shortcuts/hooks/useTopSites.ts b/packages/shared/src/features/shortcuts/hooks/useTopSites.ts index f5a11f590c8..786ca92062a 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; @@ -16,7 +17,7 @@ export const useTopSites = () => { try { 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/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 86fd312676f..52354d7aa68 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -28,6 +28,7 @@ export type SettingsFlags = { shortcutMeta?: Record; shortcutsMode?: ShortcutsMode; shortcutsAppearance?: ShortcutsAppearance; + showShortcutsOnWebapp?: boolean; }; export enum SidebarSettingsFlags { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index b20fd904ca3..c85fef03cfc 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -206,6 +206,9 @@ export enum LogEvent { 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', diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index b6b951c78bb..5b7910b1f78 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'; @@ -396,7 +397,9 @@ export default function App( - + + + From 378bb46b4c81b4b55ea4689ad6934ef73a17f473 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 10:40:12 +0300 Subject: [PATCH 12/32] fix(shortcuts): harden drag click-suppression across hub tiles Replace the one-shot `justDraggedRef` flag with a short 400ms time window in `ShortcutTile`, `ShortcutLinksHub`, and `WebappShortcutsRow`. Browsers synthesize a `click` on pointerup after a dnd-kit drag, and because tiles reorder under the pointer at drop time that click sometimes lands on a sibling where the origin-pointer guard has no record. The window catches both the stray click and any follow-ups without navigating the link. Also drop the right-click-to-open handler from the hub toolbar. It was inconsistent with the rest of the app's context-menu behavior and had no discoverability. Made-with: Cursor --- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 38 +++++++++++-------- .../shortcuts/components/ShortcutTile.tsx | 31 ++++++++++++++- .../components/WebappShortcutsRow.tsx | 23 +++++++++-- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index 441a5551f30..81292bff96b 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -104,19 +104,36 @@ export function ShortcutLinksHub({ // dnd-kit activates drag via pointer events; browsers still synthesize a // `click` on `pointerup` over the anchor because the element follows the - // pointer (no relative movement). We flag the drag lifecycle and swallow the - // synthesized click in the capture phase so the link never navigates. + // pointer (no relative movement). We flag the drag lifecycle and swallow + // any click that arrives in a short window afterward, so the link never + // navigates. The window (instead of a one-shot flag) guards against browsers + // that fire extra clicks, and against reorders that put a *different* tile + // under the pointer at drop time. const justDraggedRef = useRef(false); + const justDraggedTimerRef = useRef(null); const armDragSuppression = () => { justDraggedRef.current = true; + if (justDraggedTimerRef.current !== null) { + window.clearTimeout(justDraggedTimerRef.current); + } + justDraggedTimerRef.current = window.setTimeout(() => { + justDraggedRef.current = false; + justDraggedTimerRef.current = null; + }, 400); }; + useEffect(() => { + return () => { + if (justDraggedTimerRef.current !== null) { + window.clearTimeout(justDraggedTimerRef.current); + } + }; + }, []); const suppressClickCapture = (event: React.MouseEvent) => { if (!justDraggedRef.current) { return; } event.preventDefault(); event.stopPropagation(); - justDraggedRef.current = false; }; const loggedRef = useRef(null); @@ -287,20 +304,10 @@ export function ShortcutLinksHub({ // why the row is empty and can grant access or switch back to manual. const showAutoEmptyState = isAuto && visibleShortcuts.length === 0; - // Controlled open state so (a) the trigger stays visible while the menu - // is open even when the user hovers *into* the floating menu content and - // (b) right-clicking the toolbar background can open the menu too. + // Controlled open state so the trigger stays visible while the menu is + // open even when the user hovers *into* the floating menu content. const [menuOpen, setMenuOpen] = useState(false); - // Right-click anywhere on the toolbar background opens the same menu. - // Matches the Chrome bookmarks bar / Finder sidebar pattern. Individual - // tiles already handle their own contextmenu (edit/remove) and call - // `stopPropagation`, so this only fires on the empty space between tiles. - const handleToolbarContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - setMenuOpen(true); - }; - // Force the trigger visible in these cases so users aren't trapped: // - the menu is already open (don't yank the trigger mid-hover) // - the auto-mode empty state is showing (no tiles to hover, only options) @@ -316,7 +323,6 @@ export function ShortcutLinksHub({ aria-label="Shortcuts" onClickCapture={suppressClickCapture} onAuxClickCapture={suppressClickCapture} - onContextMenu={handleToolbarContextMenu} className={classNames( // `group` powers the hover-reveal of the overflow button below. 'group/hub', diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index fae65d2a546..01101f3770a 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -4,7 +4,7 @@ import type { PointerEvent as ReactPointerEvent, ReactElement, } from 'react'; -import React, { useCallback, useRef, useState } 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'; @@ -152,9 +152,36 @@ export function ShortcutTile({ [], ); + // `isDragging` can flip back to false *before* the browser fires the stray + // `click` that follows pointerup on a drag. And because dnd-kit reorders + // tiles under the pointer, that click sometimes lands on a sibling tile + // where `didPointerTravel` has no recorded origin to compare against. A + // short "just dragged" window catches both cases reliably. + 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; + }, 400); + return () => window.clearTimeout(timer); + }, [isDragging]); + const handleAnchorClick = useCallback( (event: MouseEvent) => { - if (isDragging || didPointerTravel(event)) { + if ( + isDragging || + justDraggedRef.current || + didPointerTravel(event) + ) { event.preventDefault(); event.stopPropagation(); return; diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx index 93c4acc125e..4e3b6c5d303 100644 --- a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -103,19 +103,36 @@ export function WebappShortcutsRow({ ); // Same click-suppression guard the extension hub uses: dnd-kit swallows - // the pointerdown→up sequence but the browser still fires a click on - // release, so we have to intercept or the link would navigate mid-drag. + // the pointerdown to pointerup sequence but the browser still fires a + // click on release, so we intercept it (otherwise the link would + // navigate mid-drag). Uses a short time window rather than a one-shot + // flag so reorders that move tiles under the pointer at drop time still + // get their stray clicks suppressed. const justDraggedRef = useRef(false); + const justDraggedTimerRef = useRef(null); const armDragSuppression = () => { justDraggedRef.current = true; + if (justDraggedTimerRef.current !== null) { + window.clearTimeout(justDraggedTimerRef.current); + } + justDraggedTimerRef.current = window.setTimeout(() => { + justDraggedRef.current = false; + justDraggedTimerRef.current = null; + }, 400); }; + useEffect(() => { + return () => { + if (justDraggedTimerRef.current !== null) { + window.clearTimeout(justDraggedTimerRef.current); + } + }; + }, []); const suppressClickCapture = (event: React.MouseEvent) => { if (!justDraggedRef.current) { return; } event.preventDefault(); event.stopPropagation(); - justDraggedRef.current = false; }; const handleDragEnd = (event: DragEndEvent) => { From f856ea5f61ef11222a1fd111069aff53ba31b1aa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 10:40:30 +0300 Subject: [PATCH 13/32] refactor(shortcuts): regroup manage modal, simplify import picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage modal: - Drop the per-section icon chips for a plainer settings rhythm (Linear/GitHub preferences, not Raycast). - Move top-sites permission + hidden-site restore inline under the Source radio when "Most visited sites" is selected. They belong to that choice, not to Connections. - Shrink Connections to just Bookmarks + web app sync; rename the sync row to "Show on daily.dev web app" so it reads as a mirror, not a cloud push. - Flag the auto-mode radio with a Chrome glyph to hint the data source. Import picker: - Drop the capacity fill bar. Picking a few sites isn't a progress bar; a calm status strip ("3 picked · 9 of 12 slots left") communicates the same info without the "fill me up" pressure. - Selection is carried by the trailing check alone; selected rows get a hair of surface tint, not an accent-color fill. - Accept a `returnTo` modal so Cancel hands control back to the caller (e.g. Manage) instead of dismissing the whole flow. The button relabels to "Back" in that case. Plumbing: - `setShowImportSource` now takes an optional `returnTo` arg, stored on the shortcuts context and forwarded to the picker. - Profile menu "Shortcuts" routes through the hub feature flag, so signed-in users on the new hub land in ShortcutsManage instead of the legacy CustomLinks modal. - Minor copy cleanups (drop a few em-dashes, soften the picker's "shared" phrasing to "available"). Made-with: Cursor --- .../ShortcutLinks/ShortcutGetStarted.tsx | 2 +- .../ShortcutLinks/ShortcutImportFlow.tsx | 6 +- .../ProfileMenu/sections/ExtensionSection.tsx | 16 +- .../shortcuts/components/AddShortcutTile.tsx | 2 +- .../components/modals/ImportPickerModal.tsx | 192 ++++++++-------- .../components/modals/ShortcutEditModal.tsx | 2 +- .../modals/ShortcutsManageModal.tsx | 211 ++++++++---------- .../shortcuts/contexts/ShortcutsProvider.tsx | 20 +- 8 files changed, 218 insertions(+), 233 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx index bbc1e96e043..829d80a1427 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx @@ -152,7 +152,7 @@ export const ShortcutGetStarted = ({

    Pin the sites you hit every day. Tap a suggestion below for a - one-click start — or add your own. + one-click start, or add your own.

    ({ url: s.url })); openModal({ type: LazyModal.ImportPicker, - props: { source: 'topSites', items }, + props: { source: 'topSites', items, returnTo: returnToAfterImport }, }); setShowImportSource?.(null); return; @@ -105,7 +106,7 @@ export function ShortcutImportFlow(): ReactElement | null { const items = bookmarks.map((b) => ({ url: b.url, title: b.title })); openModal({ type: LazyModal.ImportPicker, - props: { source: 'bookmarks', items }, + props: { source: 'bookmarks', items, returnTo: returnToAfterImport }, }); setShowImportSource?.(null); } @@ -119,6 +120,7 @@ export function ShortcutImportFlow(): ReactElement | null { displayToast, openModal, setShowImportSource, + returnToAfterImport, ]); // Permission modals: shown when the user asked to import but the browser diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index 2b333802903..aa77eefeafc 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -9,11 +9,25 @@ import { PauseIcon, PlayIcon, ShortcutsIcon, StoryIcon } from '../../icons'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; import { checkIsExtension } from '../../../lib/func'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureShortcutsHub } from '../../../lib/featureManagement'; export const ExtensionSection = (): ReactElement | null => { const { openModal } = useLazyModal(); const { isActive: isDndActive, setShowDnd } = useDndContext(); const { optOutCompanion, toggleOptOutCompanion } = useSettingsContext(); + const { user } = useAuthContext(); + // Route "Shortcuts" in the profile menu to the same modal the user sees + // elsewhere. On the new hub that's ShortcutsManage (settings-like); on + // the legacy hub it's still CustomLinks. Gating this through the same + // feature flag keeps the menu consistent with the row on the new tab. + const { value: hubEnabled } = useConditionalFeature({ + feature: featureShortcutsHub, + shouldEvaluate: !!user, + }); + const shortcutsModal = + user && hubEnabled ? LazyModal.ShortcutsManage : LazyModal.CustomLinks; if (!checkIsExtension()) { return null; @@ -28,7 +42,7 @@ export const ExtensionSection = (): ReactElement | null => { { title: 'Shortcuts', icon: ShortcutsIcon, - onClick: () => openModal({ type: LazyModal.CustomLinks }), + onClick: () => openModal({ type: shortcutsModal }), }, { title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, diff --git a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx index 0b4bf0ee179..86a06f68629 100644 --- a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx @@ -165,7 +165,7 @@ export function AddShortcutTile({ 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent', )} aria-label={`Add shortcut${dropHint}`} - title={canAcceptDrop ? 'Add shortcut — or drop a link here' : 'Add shortcut'} + title={canAcceptDrop ? 'Add shortcut or drop a link here' : 'Add shortcut'} > {iconBox} diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx index e41a0af3af2..48a68b0f955 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -20,6 +20,8 @@ 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 { LazyModal } from '../../../../components/modals/common/types'; export interface ImportPickerItem { url: string; @@ -30,6 +32,10 @@ export interface ImportPickerModalProps extends ModalProps { source: ImportSource; items: ImportPickerItem[]; onImported?: (result: { imported: number; skipped: number }) => void; + // When set, the Cancel button hands control back to this modal instead of + // fully dismissing the stack. Keeps "cancel the import" distinct from + // "close the whole flow" (which the header X still does). + returnTo?: LazyModal; } // Favicon with graceful fallback: the browser-icon proxy often ships a blurry @@ -72,11 +78,29 @@ 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 close = () => { + closeModal(); + props.onRequestClose?.(undefined as never); + }; + + // Cancel = "back out of the import", not "close the whole shortcuts flow". + // If the picker was triggered from another modal (e.g. Manage), hand + // control back there so the user lands where they came from. + const handleCancel = () => { + if (returnTo) { + openModal({ type: returnTo }); + return; + } + close(); + }; const alreadyUsed = customLinks?.length ?? 0; const capacity = Math.max(0, MAX_SHORTCUTS - alreadyUsed); @@ -127,7 +151,7 @@ export default function ImportPickerModal({ source === 'bookmarks' ? 'bookmarks' : 'sites' } to shortcuts${result.skipped ? `. ${result.skipped} skipped.` : ''}`, ); - props.onRequestClose?.(undefined as never); + close(); }; const isBookmarks = source === 'bookmarks'; @@ -135,19 +159,14 @@ export default function ImportPickerModal({ // Spell out where the list came from and how many rows the browser surfaced. // Stops users assuming we've clipped the list at whatever number they see // (Chrome's topSites API, for instance, returns however many repeat-visit - // origins the profile has — sometimes 8, sometimes 20). + // origins the profile has, sometimes 8, sometimes 20). const sourceCopy = isBookmarks - ? `Tap to pick. Your bookmarks stay untouched — ${items.length} found.` - : `Tap to pick. Snapshot from your browser — ${items.length} site${ + ? `Pick the ones you want. Your bookmarks stay untouched. ${items.length} available.` + : `Pick the ones you want. Snapshot from your browser. ${items.length} site${ items.length === 1 ? '' : 's' - } shared.`; + } available.`; - // Capacity meter always represents the full library (MAX_SHORTCUTS slots), - // so users can see at a glance that the limit is 12 — not whatever number - // is left after their existing shortcuts. Three zones stack across it: - // already saved (muted), currently picking (accent), free (empty). - const pips = Array.from({ length: MAX_SHORTCUTS }); - const filledEnd = alreadyUsed + selected.length; + const slotsLeft = Math.max(0, capacity - selected.length); return ( @@ -160,56 +179,37 @@ export default function ImportPickerModal({ -

    {sourceCopy}

    - {/* Capacity bar: count + pip row + inline select-all toggle. Three - affordances packed into one compact strip so the body can breathe. */} -
    -
    -
    - - {filledEnd} - /{MAX_SHORTCUTS} - - - {alreadyUsed > 0 - ? `${alreadyUsed} saved · ${selected.length} picked` - : `${selected.length} picked`} - -
    - +

    {sourceCopy}

    + {/* Calm status strip: what you've picked + how many slots you have + left, and an inline Select-all / Clear-all toggle. No fill bar, + no "progress to fill" metaphor. Picking is optional, not a + task. */} +
    +
    + + {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` : ''}`} +
    -
    - {pips.map((_, idx) => { - const inSaved = idx < alreadyUsed; - const inPicked = !inSaved && idx < filledEnd; - return ( - - ); - })} -
    + {allSelected ? 'Clear all' : 'Select all'} +
    {items.length === 0 ? ( @@ -238,7 +238,7 @@ export default function ImportPickerModal({ > {isBookmarks ? 'Add bookmarks to your browser bar, then come back.' - : 'Visit a few sites first — your browser needs history to suggest from.'} + : 'Visit a few sites first. Your browser needs history to suggest from.'}
    ) : ( @@ -263,23 +263,17 @@ export default function ImportPickerModal({ disabled={atCap} onClick={() => toggle(item.url)} className={classNames( - // Quiet-by-default row that picks up a subtle cabbage - // tint + thin accent bar on the leading edge when - // selected, so a full page of rows reads as "these - // are picked" instantly without shouting. - 'group relative flex w-full items-center gap-3 rounded-12 p-2 text-left transition-all duration-150 active:scale-[0.995] motion-reduce:transform-none motion-reduce:transition-none', + // Selection is carried by the trailing check alone so + // a long list of picked rows doesn't look like a wall + // of colour. Selected rows get a hair of surface tint + // to feel "lifted", nothing more. + 'group relative flex w-full items-center gap-3 rounded-12 p-2 text-left transition-colors duration-150 motion-reduce:transition-none', isChecked - ? 'bg-overlay-float-cabbage/50' - : 'hover:bg-surface-float', + ? 'bg-surface-float hover:bg-surface-float' + : 'hover:bg-surface-float/60', atCap && 'cursor-not-allowed opacity-40', )} > - {isChecked && ( - - )}

    @@ -289,17 +283,14 @@ export default function ImportPickerModal({ {getDomainFromUrl(item.url)}

    - {/* Selection indicator on the trailing edge — reads as - "this row is picked" the moment you glance at it, - instead of squinting at a tiny badge tucked behind - the favicon. Empty ring at rest gives the row a - clear "tap me" affordance. */} + {/* The only selection signal: a small filled check on the + trailing edge. Empty ring at rest invites a tap. */} @@ -318,31 +309,22 @@ export default function ImportPickerModal({ )} - - - {atCapacity - ? `Library full (${MAX_SHORTCUTS}/${MAX_SHORTCUTS})` - : `${Math.max(0, capacity - selected.length)} of ${MAX_SHORTCUTS} slot${ - capacity - selected.length === 1 ? '' : 's' - } free`} - -
    - - -
    + + + ); diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 9bb1c6f5480..26397be297b 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -353,7 +353,7 @@ export default function ShortcutEditModal({ ) : customIconFailed ? ( - Couldn't load that image — showing favicon instead + Couldn't load that image. Showing favicon instead. ) : ( diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index b0b0138d906..0b67f177d45 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -38,17 +38,13 @@ import { DragIcon, EarthIcon, EditIcon, - EyeIcon, - LayoutIcon, - LinkIcon, - MagicIcon, PlusIcon, RefreshIcon, - SitesIcon, StarIcon, TrashIcon, VIcon, } from '../../../../components/icons'; +import { ChromeIcon } from '../../../../components/icons/Browser/Chrome'; import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { useLogContext } from '../../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../../lib/log'; @@ -62,31 +58,21 @@ import { getDomainFromUrl } from '../../../../lib/links'; import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../../types'; import type { Shortcut, ShortcutsAppearance } from '../../types'; -// Reusable section header that anchors every top-level group in the modal. -// The small glyph chip on the left gives each section a unique "family crest" -// so users can scan the modal vertically and know where they are at a glance -// (Apple System Settings / Raycast pattern). The chip stays neutral by -// default and picks up a subtle accent tint when the section is the active -// subject — we use that only on Appearance right now but the API is ready. +// Plain-text section header. Bold subhead + muted caption, no decorative +// icon chip. Keeps each group clearly delimited vertically without the +// visual weight of a leading glyph — settings rhythm closer to Linear / +// GitHub preferences than Raycast. function SectionHeader({ - icon, title, description, trailing, }: { - icon: ReactElement; title: string; description?: string; trailing?: ReactElement; }): ReactElement { return (
    - - {icon} -
    {title} @@ -139,19 +125,23 @@ function CapacityPill({ // Clean radio row. Selected state is carried entirely by the filled cabbage // dot + bold title — no background fill, so it never reads like a hover. // Hover is the only place we tint the surface, which keeps the difference -// between "you're pointing at this" and "this is selected" obvious. +// between "you're pointing at this" and "this is selected" obvious. When a +// leadingIcon is supplied it renders between the radio and the copy, which +// we use to flag the auto-mode radio as "data from your browser". function ShortcutsModeOption({ id, checked, onSelect, title, description, + leadingIcon, }: { id: string; checked: boolean; onSelect: () => void; title: string; description: string; + leadingIcon?: ReactElement; }): ReactElement { return (
    ); } - -interface SourceModeToggleItemProps { - isAuto: boolean; - onToggle: () => void; -} - -// Stable menu row that flips source mode in place. Uses the same metrics as -// standard DropdownMenuOptions rows (h-7, typo-footnote, MenuIcon wrapper) so -// the dropdown reads as one dense list — matching the PostOptionButton -// convention. The enclosing DropdownMenuItem owns click + keyboard; the -// native Switch is pointer-events-none so clicks fall through to the row -// handler and `preventDefault` on `onSelect` keeps the menu open after -// toggling (it's a setting, not an action). -function SourceModeToggleItem({ - isAuto, - onToggle, -}: SourceModeToggleItemProps): ReactElement { - return ( - { - event.preventDefault(); - onToggle(); - }} - > - - - Most visited sites - - - - ); -} - diff --git a/packages/shared/__tests__/fixture/settings.ts b/packages/shared/__tests__/fixture/settings.ts index a9f26e60d8b..5149f5bcf00 100644 --- a/packages/shared/__tests__/fixture/settings.ts +++ b/packages/shared/__tests__/fixture/settings.ts @@ -40,6 +40,8 @@ export const createTestSettings = ( updateFlag: jest.fn(), updateFlagRemote: jest.fn(), updatePromptFlag: jest.fn(), + updateShortcutMeta: jest.fn(), + removeShortcut: jest.fn(), onToggleHeaderPlacement: jest.fn(), setSettings: jest.fn(), applyThemeMode: jest.fn(), diff --git a/packages/shared/__tests__/helpers/boot.tsx b/packages/shared/__tests__/helpers/boot.tsx index f9b19c169aa..398c82cc2b0 100644 --- a/packages/shared/__tests__/helpers/boot.tsx +++ b/packages/shared/__tests__/helpers/boot.tsx @@ -71,6 +71,8 @@ export const settingsContext: SettingsContextData = { updateFlag: jest.fn(), updateFlagRemote: jest.fn(), updatePromptFlag: jest.fn(), + updateShortcutMeta: jest.fn(), + removeShortcut: jest.fn(), applyThemeMode: jest.fn(), }; diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index f7e8afed68d..9cd2e547fb7 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -306,8 +306,7 @@ export const SettingsContextProvider = ({ delete next[url]; } else { const merged = { ...(current[url] ?? {}), ...patch }; - const isEmpty = - !merged.name && !merged.iconUrl && !merged.color; + const isEmpty = !merged.name && !merged.iconUrl && !merged.color; if (isEmpty) { delete next[url]; } else { diff --git a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx index 86a06f68629..4135d370298 100644 --- a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx @@ -33,11 +33,12 @@ const extractUrlFromDrop = (event: DragEvent): string | null => { const uriList = event.dataTransfer.getData('text/uri-list'); if (uriList) { - for (const line of uriList.split(/\r?\n/)) { - const parsed = tryParse(line); - if (parsed) { - return parsed; - } + const fromUriList = uriList + .split(/\r?\n/) + .map(tryParse) + .find((parsed): parsed is string => !!parsed); + if (fromUriList) { + return fromUriList; } } const plain = event.dataTransfer.getData('text/plain'); @@ -80,7 +81,9 @@ export function AddShortcutTile({ event.preventDefault(); // `copy` is the universal "you can drop this here" cursor across browsers // and communicates intent: we're not moving the dragged thing, we're - // adding a copy of it to the shortcuts row. + // adding a copy of it to the shortcuts row. Assigning to `dropEffect` is + // the standard HTML5 DnD pattern; `event` is the only way to set it. + // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'copy'; }; @@ -165,7 +168,9 @@ export function AddShortcutTile({ 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent', )} aria-label={`Add shortcut${dropHint}`} - title={canAcceptDrop ? 'Add shortcut or drop a link here' : 'Add shortcut'} + title={ + canAcceptDrop ? 'Add shortcut or drop a link here' : 'Add shortcut' + } > {iconBox} diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index 01101f3770a..c1cccbc4536 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -25,11 +25,7 @@ import { MenuIcon as WrappingMenuIcon } from '../../../components/MenuIcon'; import { combinedClicks } from '../../../lib/click'; import { apiUrl } from '../../../lib/config'; import { getDomainFromUrl } from '../../../lib/links'; -import type { - Shortcut, - ShortcutColor, - ShortcutsAppearance, -} from '../types'; +import type { Shortcut, ShortcutColor, ShortcutsAppearance } from '../types'; const pixelRatio = typeof globalThis?.window === 'undefined' @@ -58,12 +54,12 @@ function LetterChip({ size = 'md', }: LetterChipProps): ReactElement { const letter = (name || '?').charAt(0).toUpperCase(); - const sizeClass = - size === 'lg' - ? 'size-10 text-lg' - : size === 'sm' - ? 'size-6 text-xs' - : 'size-8 text-sm'; + 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 ( ) => { - if ( - isDragging || - justDraggedRef.current || - didPointerTravel(event) - ) { + if (isDragging || justDraggedRef.current || didPointerTravel(event)) { event.preventDefault(); event.stopPropagation(); return; @@ -263,13 +255,20 @@ export function ShortcutTile({ // - tile : 76px-wide column with label underneath (Chrome new tab). // - icon : compact square (iOS dock / Arc pinned tabs). // - chip : horizontal pill with favicon + label (Chrome bookmarks bar). + let appearanceContainerClass: string; + if (isChip) { + appearanceContainerClass = + 'flex h-9 max-w-[200px] 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-[76px] 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', - isChip - ? 'flex h-9 max-w-[200px] items-center gap-2 rounded-10 bg-surface-float pl-2 pr-2 hover:bg-background-default focus-within:bg-background-default' - : isIconOnly - ? 'flex size-12 items-center justify-center rounded-12 hover:bg-surface-float focus-within:bg-surface-float' - : 'flex w-[76px] flex-col items-center rounded-14 p-2 hover:bg-surface-float focus-within:bg-surface-float', + appearanceContainerClass, draggable && 'cursor-grab active:cursor-grabbing', isDragging && 'z-10 rotate-[-2deg] bg-surface-float shadow-2 motion-reduce:rotate-0', @@ -290,6 +289,7 @@ export function ShortcutTile({ className={containerClass} title={isIconOnly || isChip ? label : undefined} > + {/* eslint-disable-next-line no-nested-ternary */} {isChip ? ( // CHIP: single pill, favicon on the left inside the pill, text right. event.stopPropagation()} className={classNames( - 'flex size-5 cursor-pointer items-center justify-center rounded-full bg-text-primary text-surface-invert opacity-0 shadow-2 transition-[opacity,background-color] duration-150 focus-visible:opacity-100 hover:bg-accent-ketchup-default hover:text-white group-hover:opacity-100 motion-reduce:transition-none', + 'flex size-5 cursor-pointer items-center justify-center rounded-full bg-text-primary text-surface-invert opacity-0 shadow-2 transition-[opacity,background-color] duration-150 hover:bg-accent-ketchup-default hover:text-white focus-visible:opacity-100 group-hover:opacity-100 motion-reduce:transition-none', actionBtnPositionClass, )} > diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx index 4e3b6c5d303..bd3746ac589 100644 --- a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -21,22 +21,12 @@ import { useLogContext } from '../../../contexts/LogContext'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { useToastNotification } from '../../../hooks/useToastNotification'; import { LazyModal } from '../../../components/modals/common/types'; -import { - LogEvent, - ShortcutsSourceType, - TargetType, -} from '../../../lib/log'; +import { LogEvent, ShortcutsSourceType, TargetType } from '../../../lib/log'; import { ShortcutTile } from './ShortcutTile'; import { AddShortcutTile } from './AddShortcutTile'; import { useShortcutsManager } from '../hooks/useShortcutsManager'; -import { - DEFAULT_SHORTCUTS_APPEARANCE, - MAX_SHORTCUTS, -} from '../types'; -import type { - Shortcut, - ShortcutsAppearance, -} from '../types'; +import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../types'; +import type { Shortcut, ShortcutsAppearance } from '../types'; interface WebappShortcutsRowProps { className?: string; @@ -189,7 +179,7 @@ export function WebappShortcutsRow({ onAuxClickCapture={suppressClickCapture} className={classNames( 'hidden flex-wrap items-center mobileXL:flex', - appearance === 'tile' && 'gap-x-1 gap-y-2 items-start', + appearance === 'tile' && 'items-start gap-x-1 gap-y-2', appearance === 'icon' && 'gap-1', appearance === 'chip' && 'gap-1', className, diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx index 48a68b0f955..ae1fdac1243 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -21,7 +21,7 @@ import { useShortcutsManager } from '../../hooks/useShortcutsManager'; import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { useToastNotification } from '../../../../hooks/useToastNotification'; import { useLazyModal } from '../../../../hooks/useLazyModal'; -import { LazyModal } from '../../../../components/modals/common/types'; +import type { LazyModal } from '../../../../components/modals/common/types'; export interface ImportPickerItem { url: string; @@ -34,8 +34,11 @@ export interface ImportPickerModalProps extends ModalProps { onImported?: (result: { imported: number; skipped: number }) => void; // When set, the Cancel button hands control back to this modal instead of // fully dismissing the stack. Keeps "cancel the import" distinct from - // "close the whole flow" (which the header X still does). - returnTo?: LazyModal; + // "close the whole flow" (which the header X still does). Narrowed to + // `ShortcutsManage` because that's the only prop-less modal we reopen + // from here; keeping it narrow avoids the generic `openModal` call + // requiring a `props` argument at the type level. + returnTo?: LazyModal.ShortcutsManage; } // Favicon with graceful fallback: the browser-icon proxy often ships a blurry @@ -129,8 +132,7 @@ export default function ImportPickerModal({ return { ...prev, [url]: next }; }); - const allSelected = - selectableCount > 0 && selected.length >= selectableCount; + const allSelected = selectableCount > 0 && selected.length >= selectableCount; const toggleAll = () => { if (allSelected) { setChecked({}); @@ -162,9 +164,9 @@ export default function ImportPickerModal({ // origins the profile has, sometimes 8, sometimes 20). const sourceCopy = isBookmarks ? `Pick the ones you want. Your bookmarks stay untouched. ${items.length} available.` - : `Pick the ones you want. Snapshot from your browser. ${items.length} site${ - items.length === 1 ? '' : 's' - } available.`; + : `Pick the ones you want. Snapshot from your browser. ${ + items.length + } site${items.length === 1 ? '' : 's'} available.`; const slotsLeft = Math.max(0, capacity - selected.length); @@ -185,11 +187,11 @@ export default function ImportPickerModal({ no "progress to fill" metaphor. Picking is optional, not a task. */}
    - + {selected.length === 0 ? 'Nothing picked yet' : `${selected.length} picked`} @@ -199,14 +201,16 @@ export default function ImportPickerModal({ ? `You've hit the ${MAX_SHORTCUTS}-shortcut limit` : `${slotsLeft} of ${MAX_SHORTCUTS} slot${ slotsLeft === 1 ? '' : 's' - } left${alreadyUsed > 0 ? ` · ${alreadyUsed} already saved` : ''}`} + } left${ + alreadyUsed > 0 ? ` · ${alreadyUsed} already saved` : '' + }`}
    @@ -216,7 +220,7 @@ export default function ImportPickerModal({ // Empty state worth looking at. The source-specific glyph tells // users what we tried to read from, and the copy explains *why* // there's nothing — not just "empty" which reads like our bug. -
    +
    ( - shortcut?.url ?? '', - ); + const [debouncedUrl, setDebouncedUrl] = useState(shortcut?.url ?? ''); const handleIconBase64 = async (base64: string, file: File) => { clearErrors('iconUrl'); @@ -121,8 +119,7 @@ export default function ShortcutEditModal({ const uploadedUrl = await uploadContentImage(file); setValue('iconUrl', uploadedUrl, { shouldDirty: true }); } catch (error) { - const message = - (error as Error)?.message ?? 'Failed to upload the image'; + const message = (error as Error)?.message ?? 'Failed to upload the image'; setError('iconUrl', { message }); displayToast(message); setValue('iconUrl', shortcut?.iconUrl ?? '', { shouldDirty: true }); @@ -188,6 +185,9 @@ export default function ShortcutEditModal({ }; const handleAvatarDragOver = (event: React.DragEvent) => { event.preventDefault(); + // Assigning to `dropEffect` is the standard HTML5 DnD pattern — the + // drop cursor is only configurable through the event's dataTransfer. + // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'copy'; }; const handleAvatarDragLeave = () => setIsDropTarget(false); @@ -264,16 +264,19 @@ export default function ShortcutEditModal({ onDragLeave={handleAvatarDragLeave} onDrop={handleAvatarDrop} aria-label={ - hasCustomIcon ? 'Replace shortcut icon' : 'Upload shortcut icon' + hasCustomIcon + ? 'Replace shortcut icon' + : 'Upload shortcut icon' } className={classNames( - 'group relative flex size-16 items-center justify-center overflow-hidden rounded-16 border bg-surface-float transition-all duration-150 hover:-translate-y-px hover:bg-surface-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-2 focus-visible:ring-offset-background-default motion-reduce:hover:transform-none motion-reduce:transition-none', + 'group relative flex size-16 items-center justify-center overflow-hidden rounded-16 border bg-surface-float transition-all duration-150 hover:-translate-y-px hover:bg-surface-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-cabbage-default focus-visible:ring-offset-2 focus-visible:ring-offset-background-default motion-reduce:transition-none motion-reduce:hover:transform-none', isDropTarget - ? 'border-accent-cabbage-default bg-overlay-float-cabbage ring-2 ring-accent-cabbage-default/30' + ? 'ring-accent-cabbage-default/30 border-accent-cabbage-default bg-overlay-float-cabbage ring-2' : 'border-border-subtlest-tertiary hover:border-border-subtlest-secondary', isUploading && 'opacity-60', )} > + {/* eslint-disable-next-line no-nested-ternary */} {hasCustomIcon ? ( ) : ( - + )} {/* Upload ring: a spinning cabbage arc that feels like real progress rather than just "something dimmed". Covers the @@ -321,6 +321,9 @@ export default function ShortcutEditModal({ className="sr-only" onChange={(event) => { onFileChange(event.target.files?.[0] ?? null); + // Reset the input so picking the same file again still + // fires a change event. + // eslint-disable-next-line no-param-reassign event.target.value = ''; }} /> @@ -332,6 +335,7 @@ export default function ShortcutEditModal({ aria-live="polite" className="flex min-h-[18px] flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-center text-text-tertiary typo-caption1" > + {/* eslint-disable-next-line no-nested-ternary */} {isUploading ? ( @@ -343,6 +347,7 @@ export default function ShortcutEditModal({ ) : ( <> + {/* eslint-disable-next-line no-nested-ternary */} {hasCustomIcon ? ( - ); -} function ShortcutItemPlaceholder({ children }: PropsWithChildren) { return (
    -
    +
    {children}
    @@ -101,95 +30,51 @@ function ShortcutItemPlaceholder({ children }: PropsWithChildren) { interface ShortcutGetStartedProps { onTopSitesClick: () => void; onCustomLinksClick: () => void; - onImportClick?: () => void; } export const ShortcutGetStarted = ({ onTopSitesClick, onCustomLinksClick, - onImportClick, }: ShortcutGetStartedProps): ReactElement => { const { githubShortcut } = useThemedAsset(); const { completeAction, checkHasCompleted } = useActions(); - const manager = useShortcutsManager(); - const { displayToast } = useToastNotification(); - const suggestions = buildSuggestions(githubShortcut); + const items = [ + cloudinaryShortcutsIconsGmail, + githubShortcut, + cloudinaryShortcutsIconsReddit, + cloudinaryShortcutsIconsOpenai, + cloudinaryShortcutsIconsStackoverflow, + ]; - const markStarted = () => { + const completeActionThenFire = (callback?: () => void) => { if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { completeAction(ActionType.FirstShortcutsSession); } - }; - - const completeActionThenFire = (callback?: () => void) => { - markStarted(); callback?.(); }; - // Add a single suggested site without leaving the empty state. Duplicate - // detection lives in the manager, so a user who somehow already has the - // URL (e.g. imported earlier) gets a clear toast instead of a silent no-op. - const addSuggestion = async (site: SuggestedSite) => { - const result = await manager.addShortcut({ - url: site.url, - name: site.name, - }); - if (result.error) { - displayToast(result.error); - return; - } - markStarted(); - }; - - // "Quick pick" seeds the whole starter pack in one shot. Uses the same - // importFrom path that the browser-bookmarks importer uses so dedupe and - // capacity handling are consistent. - const addAllSuggestions = async () => { - const result = await manager.importFrom( - 'topSites', - suggestions.map((s) => ({ url: s.url, title: s.name })), - ); - if (result.imported === 0) { - displayToast('All these shortcuts already exist.'); - return; - } - markStarted(); - displayToast( - `Added ${result.imported} shortcut${result.imported === 1 ? '' : 's'}.`, - ); - }; - return ( -
    -
    -
    -
    -

    - Choose your most visited sites -

    -

    - Pin the sites you hit every day. Tap a suggestion below for a - one-click start, or add your own. -

    -
    -
    - {suggestions.map((site) => ( - +
    +

    + Choose your most visited sites +

    +
    + {items.map((url) => ( + + {`Icon + ))}
    -
    +
    - {onImportClick && ( - - )} - diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 1c63ef20879..0de11a071ab 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -137,7 +137,6 @@ function NewShortcutLinks({ const { showTopSites, toggleShowTopSites } = useSettingsContext(); const manager = useShortcutsManager(); const { openModal } = useLazyModal(); - const { setShowImportSource } = useShortcuts(); useShortcutsMigration(); if (!showTopSites) { @@ -152,7 +151,6 @@ function NewShortcutLinks({ onCustomLinksClick={() => openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }) } - onImportClick={() => setShowImportSource?.('topSites')} /> diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index b425702699c..b40016dba35 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -33,8 +33,8 @@ import { EyeIcon, MenuIcon, SettingsIcon, - SitesIcon, } from '@dailydotdev/shared/src/components/icons'; +import { ChromeIcon } from '@dailydotdev/shared/src/components/icons/Browser/Chrome'; import { MenuIcon as WrappingMenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; @@ -91,7 +91,7 @@ function SourceModeToggleItem({ }} > - + Most visited sites } className={classNames( - 'ml-1 !size-8 !min-w-0 rounded-full text-text-tertiary transition-opacity duration-150 hover:bg-surface-float hover:text-text-primary motion-reduce:transition-none', + 'ml-1 !size-8 !min-w-0 !rounded-10 text-text-tertiary transition-opacity duration-150 hover:bg-surface-float hover:text-text-primary motion-reduce:transition-none', // Quiet by default, reveals when the user shows intent: // - hovering anywhere on the row // - keyboard-focusing any child (focus-within) diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index c1cccbc4536..262e365640e 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -1,4 +1,5 @@ import type { + DragEvent as ReactDragEvent, KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent, @@ -224,6 +225,19 @@ export function ShortcutTile({ const dragHandleProps = draggable ? { ...attributes, ...listeners } : {}; + // Anchors (``) and images are natively draggable via the browser's + // HTML5 drag-and-drop. With dnd-kit's PointerSensor using a 5px activation + // threshold, the browser can start its own URL-drag before dnd-kit takes + // over. If the user drops that URL outside a registered drop zone — + // anywhere to the *left* of `AddShortcutTile` — Chrome's default action is + // to navigate the current tab to the URL, which looks exactly like a + // stray click. Swallowing `dragstart` at the tile root disables native + // HTML5 drag for the anchor and favicon without affecting dnd-kit (which + // listens to pointer events, not drag events). + const suppressNativeDrag = useCallback((event: ReactDragEvent) => { + event.preventDefault(); + }, []); + const isChip = appearance === 'chip'; const isIconOnly = appearance === 'icon'; @@ -286,6 +300,7 @@ export function ShortcutTile({ ref={setNodeRef} style={style} {...dragHandleProps} + onDragStart={suppressNativeDrag} className={containerClass} title={isIconOnly || isChip ? label : undefined} > diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 5b999cf015a..62581d216be 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -43,6 +43,7 @@ import { DragIcon, EarthIcon, EditIcon, + LinkIcon, PlusIcon, RefreshIcon, StarIcon, @@ -180,23 +181,25 @@ function CapacityPill({ // Clean radio row. Selected state is carried entirely by the filled cabbage // dot + bold title — no background fill, so it never reads like a hover. // Hover is the only place we tint the surface, which keeps the difference -// between "you're pointing at this" and "this is selected" obvious. When a -// leadingIcon is supplied it renders between the radio and the copy, which -// we use to flag the auto-mode radio as "data from your browser". +// between "you're pointing at this" and "this is selected" obvious. An +// optional `trailingBadge` sits on the right (kept out of the radio/text +// column) so we can flag a row with a brand mark — e.g. the Chrome glyph +// on the auto-mode row — without knocking the radio bullet and copy out +// of alignment. function ShortcutsModeOption({ id, checked, onSelect, title, description, - leadingIcon, + trailingBadge, }: { id: string; checked: boolean; onSelect: () => void; title: string; description: string; - leadingIcon?: ReactElement; + trailingBadge?: ReactElement; }): ReactElement { return ( - {leadingIcon && ( - - {leadingIcon} - - )}

    {description}

    + {trailingBadge && ( + + {trailingBadge} + + )} ); } @@ -653,7 +656,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { onSelect={() => selectMode('auto')} title="Most visited sites" description="Pulled automatically from your browser history." - leadingIcon={} + trailingBadge={} />
    {/* Auto-mode inline controls. When the user picks "Most @@ -665,7 +668,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { {mode === 'auto' && (
    } + icon={} label="Browser access" description={ topSitesKnown diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts index 2b286ebae19..64eb4ffbace 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts @@ -40,6 +40,17 @@ export const useShortcutsMigration = (): void => { ranRef.current = true; 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; } From 9ce80316cf8bf2ebe13357bce23923c2ff06aeac Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 12:46:59 +0300 Subject: [PATCH 16/32] fix(shortcuts): stop tile drag from navigating the tab Three gaps surfaced during PR review: - `NewShortcutLinks` flipped auto-mode users back to the onboarding card because it only checked `manager.shortcuts.length`. `customLinks` is always empty in auto mode (the hub reads live top-sites), so the hub never got a chance to render. Gate onboarding on `mode === 'manual'`. - `useShortcutsMigration` would silently import top-sites into `customLinks` for users already in auto mode, leaving a stale manual list behind the live row. Latch the migration action in auto mode without writing anything. - Dragging a tile to an area outside the hub navigated the tab to the shortcut URL. The previous guard preventDefault'd `dragstart` at the tile root, but `` and `` default to `draggable="true"` and Chrome can commit a URL drag before the delegated React handler runs. Mark anchors + favicons `draggable={false}` at the DOM level, and add a capture-phase `onDragStartCapture` backstop on both the extension hub and the webapp shortcuts toolbars. Made-with: Cursor --- .../src/newtab/ShortcutLinks/ShortcutLinks.tsx | 11 +++++++++-- .../src/newtab/ShortcutLinks/ShortcutLinksHub.tsx | 10 ++++++++++ .../features/shortcuts/components/ShortcutTile.tsx | 10 +++++++++- .../shortcuts/components/WebappShortcutsRow.tsx | 9 +++++++++ .../shortcuts/hooks/useShortcutsMigration.ts | 12 +++++++++++- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 0de11a071ab..c80e19275ff 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -134,7 +134,7 @@ function LegacyShortcutLinks({ function NewShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement { - const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); const manager = useShortcutsManager(); const { openModal } = useLazyModal(); useShortcutsMigration(); @@ -143,7 +143,14 @@ function NewShortcutLinks({ return <>; } - if (manager.shortcuts.length === 0) { + // Auto mode renders live top sites from the browser and ships its own + // permission CTA / empty state inside the hub, so an empty `customLinks` + // is not a signal to show onboarding. Only manual-mode users with zero + // curated shortcuts should see the "Choose your most visited sites" card. + const mode = flags?.shortcutsMode ?? 'manual'; + const showOnboarding = mode === 'manual' && manager.shortcuts.length === 0; + + if (showOnboarding) { return ( <> { + event.preventDefault(); + }; + const loggedRef = useRef(null); useEffect(() => { if (!showTopSites) { @@ -365,6 +374,7 @@ export function ShortcutLinksHub({ aria-label="Shortcuts" onClickCapture={suppressClickCapture} onAuxClickCapture={suppressClickCapture} + onDragStartCapture={suppressNativeDragCapture} className={classNames( // `group` powers the hover-reveal of the overflow button below. 'group/hub', diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index 262e365640e..267bc5279c2 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -243,11 +243,15 @@ export function ShortcutTile({ // Favicon/letter renderer, sized per appearance. Chip mode uses a smaller // 16px glyph to fit the compact pill; tile/icon modes stay at the roomier - // 24px favicon the rest of the feature uses. + // 24px favicon the rest of the feature uses. `draggable={false}` kills + // the browser's default image drag so dnd-kit's pointer lifecycle is the + // only drag semantics on the tile — a stray drop outside the hub can no + // longer hand Chrome a URL to navigate the tab to. const iconContent = shouldShowFavicon ? ( @@ -257,9 +261,13 @@ export function ShortcutTile({ // Anchor (the clickable favicon box). Tile/icon modes make it the whole // square; chip mode makes it a compact slot inside a horizontal pill. + // `draggable={false}` at the DOM level — belt to the `onDragStart` + // preventDefault suspenders — because Chrome otherwise starts a URL drag + // on mousedown before React's delegated handler can cancel it. const anchorCommon = { href: url, rel: 'noopener noreferrer', + draggable: false, onPointerDown: handlePointerDown, onKeyDown: handleKey, 'aria-label': label, diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx index bd3746ac589..1a5799e3d7d 100644 --- a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -125,6 +125,14 @@ export function WebappShortcutsRow({ event.stopPropagation(); }; + // Match the extension hub's native-drag backstop. Tiles already mark their + // anchors/favicons as `draggable={false}`, but capture-phase cancellation + // at the toolbar root kills any stray URL drag before the browser can + // navigate the tab on drop-outside-a-handler. + const suppressNativeDragCapture = (event: React.DragEvent) => { + event.preventDefault(); + }; + const handleDragEnd = (event: DragEndEvent) => { armDragSuppression(); const { active, over } = event; @@ -177,6 +185,7 @@ export function WebappShortcutsRow({ aria-label="Shortcuts" onClickCapture={suppressClickCapture} onAuxClickCapture={suppressClickCapture} + onDragStartCapture={suppressNativeDragCapture} className={classNames( 'hidden flex-wrap items-center mobileXL:flex', appearance === 'tile' && 'items-start gap-x-1 gap-y-2', diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts index 64eb4ffbace..5f25c4f7f5e 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts @@ -21,7 +21,7 @@ import { useShortcuts } from '../contexts/ShortcutsProvider'; * on the next mount. */ export const useShortcutsMigration = (): void => { - const { customLinks } = useSettingsContext(); + const { customLinks, flags } = useSettingsContext(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const { topSites, hasCheckedPermission } = useShortcuts(); const manager = useShortcutsManager(); @@ -40,6 +40,15 @@ export const useShortcutsMigration = (): void => { 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 — @@ -84,6 +93,7 @@ export const useShortcutsMigration = (): void => { checkHasCompleted, completeAction, customLinks, + flags, topSites, manager, displayToast, From bb785c1c26584837944eb10b7844ee0f00f84d7d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 12:51:36 +0300 Subject: [PATCH 17/32] fix(profile-menu): correct Section/common import paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MainSection` imports `../Section` and `../common`, which resolve to `ProfileMenu/Section` and `ProfileMenu/common` — neither exists. The real modules live under `components/sidebar/`, so build_extension was failing with "Module not found" on the chrome bundle. Point the imports at the sidebar package so the extension compiles again. Made-with: Cursor --- .../src/components/ProfileMenu/sections/MainSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx index d142584e848..b579ba2cc64 100644 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx @@ -1,8 +1,8 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; -import { Section } from '../Section'; -import type { SidebarMenuItem } from '../common'; -import { ListIcon } from '../common'; +import { Section } from '../../sidebar/Section'; +import type { SidebarMenuItem } from '../../sidebar/common'; +import { ListIcon } from '../../sidebar/common'; import { DevPlusIcon, EyeIcon, From 31e0323a2aaffbb0555bdf2fcc3257b85000a4dd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 12:56:25 +0300 Subject: [PATCH 18/32] fix(profile-menu): point SidebarSectionProps import at sidebar/sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict typecheck on CI still failed after the previous rebase because `MainSection` also imports `./common`, which resolves to `ProfileMenu/sections/common` — a file that doesn't exist. The type lives in `sidebar/sections/common`, so reach across like the other imports in this file already do. Made-with: Cursor --- .../shared/src/components/ProfileMenu/sections/MainSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx index b579ba2cc64..ae7e320586f 100644 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx @@ -16,7 +16,7 @@ import { import { useAuthContext } from '../../../contexts/AuthContext'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; import { OtherFeedPage } from '../../../lib/query'; -import type { SidebarSectionProps } from './common'; +import type { SidebarSectionProps } from '../../sidebar/sections/common'; import { plusUrl, webappUrl } from '../../../lib/constants'; import useCustomDefaultFeed from '../../../hooks/feed/useCustomDefaultFeed'; import { SharedFeedPage } from '../../utilities'; From f73489eaa00fc54972bf7e677decdf22476fbb03 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 13:29:12 +0300 Subject: [PATCH 19/32] fix(sidebar): restore anonymous-user test by reshaping renderComponent `Sidebar.spec.tsx` was failing `should require login before opening following for anonymous users` on main and on every PR that merged main in (including this one). Root cause is an ES default-parameter gotcha introduced by ee36f20e7: user: LoggedUser | undefined = defaultUser Callers meant to pass `undefined` to exercise the anonymous-user path, but default-parameter semantics fire whenever the argument is `undefined`, so the helper silently logged the test user back in. With `!user` evaluating to false, `SidebarItem` never wired up the login-required onClick and `showLogin` was never called. Swap the positional signature for an options bag with an explicit `isAnonymous` flag. `user` still falls back to `defaultUser` for the logged-in cases, but "no user" now has its own discriminant and can't be erased by a passed-in `undefined`. Existing call sites updated. All 11 Sidebar.spec tests now pass. Made-with: Cursor --- .../src/components/sidebar/Sidebar.spec.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx index f33c716874c..a41d5706c11 100644 --- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx @@ -35,12 +35,24 @@ const createMockFeedSettings = () => ({ const defaultAlerts: Alerts = { filter: true }; +interface RenderOptions { + // Callers pass `undefined` explicitly to mean "anonymous user" — we can't + // rely on a default-param `= defaultUser`, because ES default-parameter + // semantics fire on `undefined`, which would silently log the test user in + // and mask anonymous-only behaviour like the login-required gate on sidebar + // items. An explicit options object sidesteps the ambiguity. + user?: LoggedUser; + isAnonymous?: boolean; + sidebarExpanded?: boolean; +} + const renderComponent = ( alertsData = defaultAlerts, mocks: MockedGraphQLResponse[] = [createMockFeedSettings()], - user: LoggedUser | undefined = defaultUser, - sidebarExpanded = true, + options: RenderOptions = {}, ): RenderResult => { + const { user, isAnonymous = false, sidebarExpanded = true } = options; + const resolvedUser = isAnonymous ? undefined : user ?? defaultUser; const settingsContext = createTestSettings({ sidebarExpanded, toggleSidebarExpanded, @@ -58,10 +70,10 @@ const renderComponent = ( > { }); it('should show the sidebar as closed if user has this set', async () => { - renderComponent(defaultAlerts, [], undefined, false); + renderComponent(defaultAlerts, [], { sidebarExpanded: false }); const trigger = await screen.findByLabelText('Open sidebar'); expect(trigger).toBeInTheDocument(); @@ -134,7 +146,9 @@ it('should render Highlights item linking to highlights page', async () => { }); it('should require login before opening following for anonymous users', async () => { - renderComponent(defaultAlerts, [createMockFeedSettings()], undefined); + renderComponent(defaultAlerts, [createMockFeedSettings()], { + isAnonymous: true, + }); const item = await screen.findByText('Following'); fireEvent.click(item); From 3e58c4c6608759891e6eb39fa80ea65907e2e14d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 14:12:16 +0300 Subject: [PATCH 20/32] fix(shortcuts): hide Connections section in auto mode When the user picks "Most visited sites" the shortcuts row is fed by browser history, so both "Bookmarks bar" (imports into a manual list) and "Show on daily.dev web app" (mirrors a manual list across devices) have nothing to act on. Gate the whole Connections section on `mode === 'manual'` to match how "Your shortcuts" already collapses, keeping the auto-mode view focused on Browser access + hidden sites. Made-with: Cursor --- .../modals/ShortcutsManageModal.tsx | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 62581d216be..90d71927519 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -792,21 +792,31 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement {
    )} - - setShowImportSource('bookmarks', LazyModal.ShortcutsManage) - : undefined - } - onAskBookmarks={askBookmarksPermission} - onRevokeBookmarks={revokeBookmarksPermission} - /> + {/* Connections (bookmarks import + web-app sync) only apply when the + user curates their own list. In auto mode the row is fed by + browser history, so importing bookmarks or mirroring a manual + list across devices is meaningless — hide the whole section to + match how "Your shortcuts" disappears above. */} + {mode === 'manual' && ( + + setShowImportSource( + 'bookmarks', + LazyModal.ShortcutsManage, + ) + : undefined + } + onAskBookmarks={askBookmarksPermission} + onRevokeBookmarks={revokeBookmarksPermission} + /> + )}
    From 64670fdd930042d7820782455b919bbe0f0ec846 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 14:18:53 +0300 Subject: [PATCH 21/32] fix(shortcuts): drop nested scroll on "Your shortcuts" list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list was capped at 50vh with its own overflow-y-auto, which stacked a second scrollbar inside the modal body's scrollbar whenever the library got long. Remove the inner cap so the modal is the single scroll surface — the Add button + rows flow naturally and the user only sees one scrollbar on the right edge. Made-with: Cursor --- .../shortcuts/components/modals/ShortcutsManageModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 90d71927519..2fe00c93a1a 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -745,7 +745,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement {
    ) : ( -
    +
    {/* Inline "Add" affordance sitting above the list. At the cap we keep it visible but disabled with a tiny "Library full" hint so users know why they can't add. */} From 681a876b1707b369d093fed97d2bc2110269bb82 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 15:13:13 +0300 Subject: [PATCH 22/32] refactor(shortcuts): extract drag-click guard + row-wide drop zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicates the drag-release click suppression and the URL drop handling across ShortcutLinksHub, WebappShortcutsRow, and the legacy ShortcutLinksList by hoisting them into two shared hooks: - useDragClickGuard installs a document-level capture-phase click swallow for the 500ms window after a drag ends, covering stray clicks that land outside the toolbar's DOM subtree (where React's synthetic onClickCapture couldn't reach). - useShortcutDropZone turns the entire shortcuts row into one drop target instead of the 44px "+" tile, uses a depth counter to survive dragenter/dragleave flicker across child boundaries, and gates the hover halo on text/uri-list only — so selected-text drags no longer light up the zone falsely (text/plain is still accepted as a fallback at drop time for Firefox). Drag magic numbers (5px activation distance, 500ms post-drag suppression) are now named constants shared between the sensor, per-tile travel detectors, and the document-level guard — so they agree by construction. Drops the deprecated aria-dropeffect attribute, fixes the React namespace import in the new .ts hook, and adds a 9-case spec covering the full drop lifecycle (empty payload, nested boundaries, text/plain fallback, RFC 2483 comment skipping, invalid-URL no-op). Also realigns the auto-mode "Connections" section in the manage modal so it mirrors the manual-mode section 1:1 (same SectionHeader + bare + )} {mode === 'manual' && ( 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..9a28278fec5 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useDragClickGuard.ts @@ -0,0 +1,109 @@ +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; + +/** + * 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/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, + }; +} From 8a47ace0d158e7be1c0a0fac543e7c2a369489e7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 15:22:16 +0300 Subject: [PATCH 23/32] fix(shortcuts): address review follow-ups on hub redesign - Drop dead `undoRef.current.timeout` branch in `useShortcutsManager.removeShortcut`: the ref was read and cleared but never assigned, so the clearTimeout never ran. The toast manager already owns the 6s undo window. - Keep legacy top-sites row capped at 8 tiles. `useTopSites` now fetches up to `MAX_SHORTCUTS` (12) for the new hub's auto mode, and `useShortcutLinks` slices to 8 downstream so flag-off users see the same row they always did. - Normalize leading `www.` in `canonicalShortcutUrl` so `example.com` and `www.example.com` dedup against each other on add and on import. Ports and non-www subdomains are preserved; adds spec coverage for the new behaviour. Made-with: Cursor --- .../shortcuts/hooks/useShortcutLinks.ts | 6 +++- .../shortcuts/hooks/useShortcutsManager.ts | 11 +++--- .../features/shortcuts/hooks/useTopSites.ts | 6 ++++ packages/shared/src/lib/links.spec.ts | 35 ++++++++++++++++++- packages/shared/src/lib/links.ts | 14 ++++++-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts index 688377461be..207c58bb19e 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts @@ -36,7 +36,11 @@ export function useShortcutLinks(): UseShortcutLinks { const hasCustomLinks = customLinks?.length > 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) || []; diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts index 97b19499bf2..30e56cce186 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSettingsContext } from '../../../contexts/SettingsContext'; import { useLogContext } from '../../../contexts/LogContext'; import { useToastNotification } from '../../../hooks/useToastNotification'; @@ -205,8 +205,6 @@ export const useShortcutsManager = (): UseShortcutsManager => { [links, metaMap, findDuplicate, writeBatch, log], ); - const undoRef = useRef<{ timeout?: ReturnType }>({}); - const removeShortcut = useCallback( async (url) => { const index = links.indexOf(url); @@ -221,10 +219,9 @@ export const useShortcutsManager = (): UseShortcutsManager => { await writeBatch(nextLinks, nextMeta); log(LogEvent.RemoveShortcut); - if (undoRef.current.timeout) { - clearTimeout(undoRef.current.timeout); - } - + // `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: { diff --git a/packages/shared/src/features/shortcuts/hooks/useTopSites.ts b/packages/shared/src/features/shortcuts/hooks/useTopSites.ts index 786ca92062a..ea2af793773 100644 --- a/packages/shared/src/features/shortcuts/hooks/useTopSites.ts +++ b/packages/shared/src/features/shortcuts/hooks/useTopSites.ts @@ -16,6 +16,12 @@ 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, MAX_SHORTCUTS)); }); diff --git a/packages/shared/src/lib/links.spec.ts b/packages/shared/src/lib/links.spec.ts index c9e3c0805de..82c8947fa0d 100644 --- a/packages/shared/src/lib/links.spec.ts +++ b/packages/shared/src/lib/links.spec.ts @@ -1,4 +1,4 @@ -import { withHttps } from './links'; +import { canonicalShortcutUrl, withHttps } from './links'; describe('lib/links tests', () => { it('should return links as https links', () => { @@ -14,4 +14,37 @@ describe('lib/links tests', () => { expect(withHttps(input)).toEqual(expected); }); }); + + describe('canonicalShortcutUrl', () => { + it('lowercases the host and strips trailing slashes', () => { + expect(canonicalShortcutUrl('HTTPS://Example.COM/Foo/')).toEqual( + 'https://example.com/Foo', + ); + }); + + it('collapses www. so www.example.com and example.com dedup', () => { + expect(canonicalShortcutUrl('https://www.example.com/')).toEqual( + 'https://example.com', + ); + expect(canonicalShortcutUrl('https://example.com')).toEqual( + 'https://example.com', + ); + }); + + it('preserves non-www subdomains', () => { + expect(canonicalShortcutUrl('https://blog.example.com')).toEqual( + 'https://blog.example.com', + ); + }); + + it('preserves non-default ports', () => { + expect(canonicalShortcutUrl('https://www.example.com:8443/path')).toEqual( + 'https://example.com:8443/path', + ); + }); + + it('returns null for invalid input', () => { + expect(canonicalShortcutUrl('not a url')).toBeNull(); + }); + }); }); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index 122cc3ba948..abc062e0279 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -31,14 +31,22 @@ export const stripLinkParameters = (link: string): string => { /** * Canonical URL form used for duplicate detection across shortcuts. - * origin + pathname, lowercased, trailing slash stripped. + * origin + pathname, lowercased, trailing slash stripped, leading `www.` + * removed from the hostname. The `www.` collapse matters because users + * routinely paste both `example.com` and `www.example.com` for the same + * site (and browsers treat them as one in top-sites/history), so without + * it the dedup pass would happily keep both tiles. */ export const canonicalShortcutUrl = (link: string): string | null => { try { const url = new URL(withHttps(link)); - const origin = url.origin.toLowerCase(); + const hostname = url.hostname.toLowerCase().replace(/^www\./, ''); const pathname = url.pathname.replace(/\/+$/, ''); - return `${origin}${pathname}`; + // `url.port` is already normalized (empty for default ports), so we + // rebuild origin from parts instead of using `url.origin` which would + // bake the `www.` back in. + const port = url.port ? `:${url.port}` : ''; + return `${url.protocol.toLowerCase()}//${hostname}${port}${pathname}`; } catch (_) { return null; } From fe5b55a3ae1ee08788c8959b368b2022511566cd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 16:19:26 +0300 Subject: [PATCH 24/32] fix(shortcuts): address PR review and unblock strict typecheck - revert SettingsContext additions (dead API surface) and remove it from the strict-typecheck skip list - tighten useShortcutLinks interface + implementation to satisfy strict mode (customLinks length, form ref, return types) - coerce ShortcutLinks.tsx caller with a fallback to string[] - flip featureShortcutsHub default to false - recompute undo toast from fresh state via refs - preserve search/hash in canonicalShortcutUrl - consolidate top-sites permission UI and drag click-guard helpers - polish import toast copy, modal close helper, shortcut migration deps - make outlined drag icon visually distinct from filled - add useShortcutsManager test coverage and align UI (main toolbar dots, +N button) with the favicon row Made-with: Cursor --- .../ShortcutLinks/ShortcutImportFlow.tsx | 43 ++--- .../newtab/ShortcutLinks/ShortcutLinks.tsx | 2 +- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 44 ++++- .../ShortcutLinks/ShortcutLinksItem.tsx | 5 +- packages/shared/__tests__/fixture/settings.ts | 2 - packages/shared/__tests__/helpers/boot.tsx | 2 - .../src/components/icons/Drag/outlined.svg | 14 +- .../shared/src/contexts/SettingsContext.tsx | 44 ----- .../shortcuts/components/ShortcutTile.tsx | 19 +- .../components/modals/ImportPickerModal.tsx | 25 ++- .../modals/MostVisitedSitesModal.tsx | 46 +---- .../MostVisitedSitesPermissionContent.tsx | 50 ++++++ .../components/modals/ShortcutEditModal.tsx | 3 +- .../modals/ShortcutsManageModal.tsx | 8 +- .../shortcuts/components/modals/closeModal.ts | 14 ++ .../shortcuts/hooks/useDragClickGuard.ts | 9 + .../shortcuts/hooks/useShortcutLinks.ts | 20 ++- .../hooks/useShortcutsManager.spec.ts | 166 ++++++++++++++++++ .../shortcuts/hooks/useShortcutsManager.ts | 37 +++- .../shortcuts/hooks/useShortcutsMigration.ts | 10 +- packages/shared/src/lib/featureManagement.ts | 2 +- packages/shared/src/lib/links.spec.ts | 12 ++ packages/shared/src/lib/links.ts | 14 +- scripts/typecheck-strict-changed.js | 1 - 24 files changed, 405 insertions(+), 187 deletions(-) create mode 100644 packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent.tsx create mode 100644 packages/shared/src/features/shortcuts/components/modals/closeModal.ts create mode 100644 packages/shared/src/features/shortcuts/hooks/useShortcutsManager.spec.ts diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx index 78392dab1dc..80df4b7c472 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -9,7 +9,12 @@ import { } 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 { LazyImage } from '@dailydotdev/shared/src/components/LazyImage'; +import { + Typography, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { MostVisitedSitesPermissionContent } from '@dailydotdev/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; @@ -144,33 +149,15 @@ export function ShortcutImportFlow(): ReactElement | null { isOpen onRequestClose={() => setShowImportSource?.(null)} > - - - Show most visited sites - - To import your most visited sites, your browser will ask for - permission. Once approved, the data is kept locally. - - - - We will never collect your browsing history. We promise. - - - - - + + + Show most visited sites + + + ); } diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index c80e19275ff..14bad4fa2f9 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -118,7 +118,7 @@ function LegacyShortcutLinks({ {...{ onLinkClick, onOptionsOpen, - shortcutLinks, + shortcutLinks: shortcutLinks ?? [], shouldUseListFeedLayout, toggleShowTopSites, onReorder, diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index 20054b8ce44..0b1e7e63d2f 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -306,8 +306,15 @@ export function ShortcutLinksHub({ const switchToAuto = async () => { await updateFlag('shortcutsMode', 'auto'); + // Auto mode is worthless without topSites permission — if the user + // declines (or it was previously denied and the browser returned no + // data), flip back to manual so they don't end up with an empty row + // and no idea how to fix it. if (!hasCheckedTopSitesPermission || topSites === undefined) { - await requestTopSitesAccess(); + const granted = await requestTopSitesAccess(); + if (!granted) { + await updateFlag('shortcutsMode', 'manual'); + } } }; @@ -343,9 +350,16 @@ export function ShortcutLinksHub({ }, ]; - // Auto mode with no permission yet: show a clear CTA tile so the user knows - // why the row is empty and can grant access or switch back to manual. + // Auto mode empty has two shapes: either the user hasn't granted topSites + // permission yet (ask for it) or they've granted it but the browser + // returned no sites (new profile, cleared history). We surface copy for + // both — "Grant access" is wrong when the user already granted and just + // has an empty history. + const autoPermissionGranted = + hasCheckedTopSitesPermission && topSites !== undefined; const showAutoEmptyState = isAuto && visibleShortcuts.length === 0; + const showAutoPermissionCta = showAutoEmptyState && !autoPermissionGranted; + const showAutoNoHistoryMessage = showAutoEmptyState && autoPermissionGranted; // Controlled open state so the trigger stays visible while the menu is // open even when the user hovers *into* the floating menu content. @@ -426,7 +440,7 @@ export function ShortcutLinksHub({ isDropActive={isDropTarget} /> )} - {showAutoEmptyState && ( + {showAutoPermissionCta && (
    diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 14bad4fa2f9..6ba2083fed1 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -156,7 +156,7 @@ function NewShortcutLinks({ - openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }) + openModal({ type: LazyModal.ShortcutsManage }) } /> 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..d903b3d2840 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx @@ -0,0 +1,388 @@ +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 { imageSizeLimitMB, uploadContentImage } from '../../../graphql/posts'; +import { useFileInput } from '../../../hooks/utils/useFileInput'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { apiUrl } from '../../../lib/config'; + +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 || + value.startsWith('data:image/') || + 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; +}; + +// Reusable form body for adding/editing a single shortcut. Rendered inside a +// standalone modal by `ShortcutEditModal` and inline inside the manage modal +// by `ShortcutsManageModal`. Leaves the action buttons to the parent so each +// host can place them in its own footer/structure — the form just exposes a +// stable `formId` to bind the submit button to and reports submit/upload +// state through `onStateChange` so the parent can disable its submit button. +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; + + // Surface submit/upload state to the parent so it can disable its submit + // button. Kept in a ref so we don't re-invoke on every render — we only + // push when the booleans actually flip. + 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); + // Drop-on-avatar: users who have a favicon.ico / logo sitting on disk + // should be able to fling it onto the icon picker without clicking + // through a file dialog. Mirrors the AddShortcutTile drop affordance. + const [isDropTarget, setIsDropTarget] = useState(false); + // Debounced copy of the URL used solely for the live favicon preview. + // Typing 15 characters shouldn't fire 15 requests to the icon proxy — + // 250ms idle matches the "I stopped typing" feel of most address bars. + 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, + 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 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 = ''; + }} + /> +
    + {/* eslint-disable-next-line no-nested-ternary */} + {isUploading ? ( + + + Uploading… + + ) : isDropTarget ? ( + + Drop to use this image + + ) : ( + <> + {/* eslint-disable-next-line no-nested-ternary */} + {hasCustomIcon ? ( + + ) : customIconFailed ? ( + + Couldn't load that image. Showing favicon instead. + + ) : ( + + {faviconSrc + ? 'Tap or drop to upload' + : 'Tap or drop an image to upload'} + + )} + + · + + + + )} +
    + {showUrlInput && ( +
    + +
    + )} +
    + +
    +
    + +
    + {nameHint} +
    +
    + +
    +
    +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx index 93d8f79c042..dbb154ffd22 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -1,9 +1,5 @@ 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 React, { useState } from 'react'; import { Button, ButtonSize, @@ -14,50 +10,14 @@ import { TypographyTag, TypographyType, } from '../../../../components/typography/Typography'; -import ControlledTextField from '../../../../components/fields/ControlledTextField'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; import { Justify } from '../../../../components/utilities'; -import { useShortcutsManager } from '../../hooks/useShortcutsManager'; import type { Shortcut } from '../../types'; -import { isValidHttpUrl, withHttps } from '../../../../lib/links'; -import { CameraIcon, EarthIcon } from '../../../../components/icons'; -import { - imageSizeLimitMB, - uploadContentImage, -} from '../../../../graphql/posts'; -import { useFileInput } from '../../../../hooks/utils/useFileInput'; -import { useToastNotification } from '../../../../hooks/useToastNotification'; import { useLazyModal } from '../../../../hooks/useLazyModal'; -import { apiUrl } from '../../../../lib/config'; import { invokeOnRequestClose } from './closeModal'; - -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 || - value.startsWith('data:image/') || - isValidHttpUrl(withHttps(value)), - 'Must be a valid URL', - ), -}); - -type FormValues = z.infer; +import type { ShortcutEditFormState } from '../ShortcutEditForm'; +import { ShortcutEditForm } from '../ShortcutEditForm'; type ShortcutEditModalProps = ModalProps & { mode: 'add' | 'edit'; @@ -65,360 +25,42 @@ type ShortcutEditModalProps = ModalProps & { onSubmitted?: () => void; }; +const FORM_ID = 'shortcut-edit-form'; + export default function ShortcutEditModal({ mode, shortcut, onSubmitted, ...props }: ShortcutEditModalProps): ReactElement { - const manager = useShortcutsManager(); - const { displayToast } = useToastNotification(); const { closeModal } = useLazyModal(); + const [formState, setFormState] = useState({ + isSubmitting: false, + isUploading: false, + }); const close = () => { closeModal(); invokeOnRequestClose(props.onRequestClose); }; - 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; - - const fileInputRef = useRef(null); - const [faviconFailed, setFaviconFailed] = useState(false); - const [customIconFailed, setCustomIconFailed] = useState(false); - // Drop-on-avatar: users who have a favicon.ico / logo sitting on disk - // should be able to fling it onto the icon picker without clicking - // through a file dialog. Mirrors the AddShortcutTile drop affordance. - const [isDropTarget, setIsDropTarget] = useState(false); - // Debounced copy of the URL used solely for the live favicon preview. - // Typing 15 characters shouldn't fire 15 requests to the icon proxy — - // 250ms idle matches the "I stopped typing" feel of most address bars. - const [debouncedUrl, setDebouncedUrl] = useState(shortcut?.url ?? ''); - - const handleIconBase64 = async (base64: string, file: File) => { - clearErrors('iconUrl'); - // Show the base64 preview immediately while the upload finishes. - 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, - onChange: handleIconBase64, - }); - - const values = watch(); - - // Reset the favicon-failed flag whenever the user edits the URL, so typing - // past a transiently-broken state recovers and shows the new favicon. - useEffect(() => { - setFaviconFailed(false); - const handle = setTimeout(() => { - setDebouncedUrl(values.url ?? ''); - }, 250); - return () => clearTimeout(handle); - }, [values.url]); - - // Same deal for the custom icon: if the user pastes a new URL, give it a - // fresh chance to load instead of keeping the broken-image state. - useEffect(() => { - setCustomIconFailed(false); - }, [values.iconUrl]); - - // Decide what to render inside the icon avatar: - // 1. A valid custom icon (uploaded, base64 preview, or pasted URL that - // actually loads). If the user pasted a broken URL we fall through to - // the favicon instead of showing a broken-image glyph. - // 2. Otherwise the site's favicon, derived from the URL as the user types. - // 3. If neither is available, fall back to a neutral Earth glyph so the - // control still looks like "a picker", not an empty circle. - 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 }); - }; - - // File drop onto the avatar: we only accept a single image file. Multiple - // files or non-images are silently ignored — the user barely interacted, - // we shouldn't punish them with a modal error. The visual ring handles - // the feedback loop (green-ish while hovering a valid file, cleared on - // leave/drop). - const handleAvatarDragEnter = (event: React.DragEvent) => { - event.preventDefault(); - if (event.dataTransfer.types.includes('Files')) { - setIsDropTarget(true); - } - }; - const handleAvatarDragOver = (event: React.DragEvent) => { - event.preventDefault(); - // Assigning to `dropEffect` is the standard HTML5 DnD pattern — the - // drop cursor is only configurable through the event's dataTransfer. - // 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); - }; - - // Live character counter for the name field. Swaps from muted to warning - // tone as we close in on the 40-char cap, so users feel the limit before - // they hit it. - const nameValue = values.name ?? ''; - const nameLen = nameValue.length; - const nameNearCap = nameLen >= 32; - const nameHint = nameLen - ? `${nameLen} / 40 characters` - : 'Up to 40 characters'; - - 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 close here so the user isn't trapped. - } - - onSubmitted?.(); - close(); - }); return ( - {/* Title uses the same Body+bold rhythm as the Manage modal so the two - surfaces feel like siblings, not different products. */} {mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} - -
    - {/* Icon-first: a single tappable avatar at the top. The favicon - derived from the URL fills it by default; uploading (or - dropping an image onto the avatar) swaps it out. */} -
    - - { - onFileChange(event.target.files?.[0] ?? null); - // Reset the input so picking the same file again still - // fires a change event. - // eslint-disable-next-line no-param-reassign - event.target.value = ''; - }} - /> - {/* All icon-related affordances live with the avatar: - upload status, remove, and the "paste URL" escape hatch. - Keeping them together means a user scanning the form - doesn't have to hunt around for icon controls. */} -
    - {/* eslint-disable-next-line no-nested-ternary */} - {isUploading ? ( - - - Uploading… - - ) : isDropTarget ? ( - - Drop to use this image - - ) : ( - <> - {/* eslint-disable-next-line no-nested-ternary */} - {hasCustomIcon ? ( - - ) : customIconFailed ? ( - - Couldn't load that image. Showing favicon instead. - - ) : ( - - {faviconSrc - ? 'Tap or drop to upload' - : 'Tap or drop an image to upload'} - - )} - - · - - - - )} -
    - {showUrlInput && ( -
    - -
    - )} -
    - -
    -
    - -
    - {nameHint} -
    -
    - -
    -
    -
    + { + onSubmitted?.(); + close(); + }} + />
    diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 40a81179c2b..317d272e87b 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import type { ReactElement } from 'react'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { DndContext, @@ -60,6 +60,8 @@ import { getDomainFromUrl } from '../../../../lib/links'; import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../../types'; import type { Shortcut, ShortcutsAppearance } from '../../types'; import { invokeOnRequestClose } from './closeModal'; +import type { ShortcutEditFormState } from '../ShortcutEditForm'; +import { ShortcutEditForm } from '../ShortcutEditForm'; // Flattened state-machine for the Browser access row's primary action. The // button can either trigger the picker (granted) or ask for permission (not @@ -478,7 +480,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { } = useShortcuts(); const { hidden: hiddenTopSites, restore: restoreHiddenTopSites } = useHiddenTopSites(); - const { openModal, closeModal } = useLazyModal(); + const { closeModal } = useLazyModal(); const close = () => { closeModal(); invokeOnRequestClose(props?.onRequestClose); @@ -569,17 +571,82 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { manager.reorder(arrayMove(urls, oldIndex, newIndex)); }; - const onEdit = (shortcut: Shortcut) => { - openModal({ - type: LazyModal.ShortcutEdit, - props: { mode: 'edit', shortcut }, - }); - }; + // Inline add/edit: we keep the form inside the manage modal instead of + // opening a separate ShortcutEdit modal. Popping a second modal closed this + // one via the LazyModal registry (only one can be open at a time), so the + // user landed on an empty surface after saving and had to reopen Manage + // from the hub. Swapping this modal's body between "list view" and "form + // view" keeps the whole flow in a single surface. + const [editing, setEditing] = useState< + { mode: 'add' } | { mode: 'edit'; shortcut: Shortcut } | null + >(null); + const [formState, setFormState] = useState({ + isSubmitting: false, + isUploading: false, + }); + + const onEdit = (shortcut: Shortcut) => setEditing({ mode: 'edit', shortcut }); const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); - const onAdd = () => - openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + const onAdd = () => setEditing({ mode: 'add' }); + + const closeEditor = () => setEditing(null); + + const EDIT_FORM_ID = 'shortcut-edit-form-manage'; + + if (editing) { + return ( + + + + {editing.mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} + + + + + +
    + + +
    +
    +
    + ); + } return ( @@ -622,15 +689,6 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { } /> - {showTopSites && ( -
    - -
    - )} - {showTopSites && (
    @@ -819,6 +877,18 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { onRevokeBookmarks={revokeBookmarksPermission} /> )} + + {/* Appearance lives at the bottom: it tweaks how the already-decided + source+list renders, so it reads better *after* users have + picked what the row shows. */} + {showTopSites && ( +
    + +
    + )}
    From 8abe1d6bc7bfccec587e64f7e859b04d412af7a8 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Apr 2026 15:07:45 +0200 Subject: [PATCH 28/32] fix: cleanup part 2 --- .../newtab/ShortcutLinks/ShortcutLinks.tsx | 15 +- .../newtab/ShortcutLinks/ShortcutLinksHub.tsx | 157 ++++-------------- .../shortcuts/components/AddShortcutTile.tsx | 20 +-- .../shortcuts/components/ShortcutEditForm.tsx | 26 +-- .../shortcuts/components/ShortcutTile.tsx | 56 ++----- .../components/WebappShortcutsRow.tsx | 54 ++---- .../components/modals/ImportPickerModal.tsx | 47 +----- .../MostVisitedSitesPermissionContent.tsx | 11 +- .../components/modals/ShortcutEditModal.tsx | 3 +- .../modals/ShortcutsManageModal.tsx | 87 ++-------- .../shortcuts/components/modals/closeModal.ts | 14 -- .../shortcuts/hooks/useBrowserBookmarks.ts | 21 +-- packages/shared/src/lib/func.ts | 2 + 13 files changed, 103 insertions(+), 410 deletions(-) delete mode 100644 packages/shared/src/features/shortcuts/components/modals/closeModal.ts diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 6ba2083fed1..ffc0950685e 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -29,7 +29,7 @@ interface ShortcutLinksProps { function LegacyShortcutLinks({ shouldUseListFeedLayout, -}: ShortcutLinksProps): ReactElement { +}: ShortcutLinksProps): ReactElement | null { const { openModal } = useLazyModal(); const { showTopSites, toggleShowTopSites, updateCustomLinks } = useSettingsContext(); @@ -102,7 +102,7 @@ function LegacyShortcutLinks({ }; if (!showTopSites) { - return <>; + return null; } return ( @@ -133,20 +133,19 @@ function LegacyShortcutLinks({ function NewShortcutLinks({ shouldUseListFeedLayout, -}: ShortcutLinksProps): ReactElement { +}: ShortcutLinksProps): ReactElement | null { const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); const manager = useShortcutsManager(); const { openModal } = useLazyModal(); useShortcutsMigration(); if (!showTopSites) { - return <>; + return null; } - // Auto mode renders live top sites from the browser and ships its own - // permission CTA / empty state inside the hub, so an empty `customLinks` - // is not a signal to show onboarding. Only manual-mode users with zero - // curated shortcuts should see the "Choose your most visited sites" card. + // 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; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index e0f1ceb6a94..f04612db012 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -75,13 +75,9 @@ interface SourceModeToggleItemProps { onToggle: () => void; } -// Stable menu row that flips source mode in place. Uses the same metrics as -// standard DropdownMenuOptions rows (h-7, typo-footnote, MenuIcon wrapper) so -// the dropdown reads as one dense list — matching the PostOptionButton -// convention. The enclosing DropdownMenuItem owns click + keyboard; the -// native Switch is pointer-events-none so clicks fall through to the row -// handler and `preventDefault` on `onSelect` keeps the menu open after -// toggling (it's a setting, not an action). +// `preventDefault` on `onSelect` keeps the menu open after toggling — this +// is a setting, not an action. Switch is pointer-events-none so the click +// lands on the DropdownMenuItem row, not the native input. function SourceModeToggleItem({ isAuto, onToggle, @@ -132,9 +128,8 @@ export function ShortcutLinksHub({ askTopSitesPermission, } = useShortcuts(); - // Default to 'manual' so existing users keep their curated lists. Auto mode - // is opt-in via the overflow menu (users who grant topSites permission and - // prefer Chrome-style live tiles). + // 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 appearance: ShortcutsAppearance = @@ -149,21 +144,15 @@ export function ShortcutLinksHub({ }), ); - // dnd-kit activates drag via pointer events; browsers still synthesize a - // `click` on `pointerup` because the tile follows the pointer via CSS - // transform, and on drops *outside* the toolbar the click target can be a - // sibling surface React's root listener never bubbles up to our handler. - // `useDragClickGuard` installs a document-level capture-phase listener so - // the stray click is swallowed wherever it lands, with the toolbar's - // `onClickCapture` kept as a React-side belt for the normal in-bounds case. + // 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(); - // Belt-and-suspenders for native HTML5 drag. Each tile already marks its - // anchor/favicon as `draggable={false}`, but capture-phase cancellation - // at the toolbar root makes it impossible for a stray child (or a - // browser that ignores the attribute) to kick off a URL drag that could - // then navigate the tab when dropped outside any drop zone. + // 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(); }; @@ -190,8 +179,6 @@ export function ShortcutLinksHub({ const [reorderAnnouncement, setReorderAnnouncement] = useState(''); - // Auto mode: render live top sites from the browser, minus any the user - // dismissed (Chrome-style). Manual mode: render the curated customLinks. const hiddenTopSitesSet = useMemo( () => new Set(hiddenTopSites), [hiddenTopSites], @@ -204,11 +191,7 @@ export function ShortcutLinksHub({ .map((site) => ({ url: site.url, name: site.title || undefined })), [topSites, hiddenTopSitesSet], ); - const manualShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); - const overflowCount = isAuto - ? 0 - : manager.shortcuts.length - manualShortcuts.length; - const visibleShortcuts = isAuto ? autoShortcuts : manualShortcuts; + const visibleShortcuts = isAuto ? autoShortcuts : manager.shortcuts; const handleDragEnd = (event: DragEndEvent) => { armDragSuppression(); @@ -219,17 +202,14 @@ export function ShortcutLinksHub({ if (!over || active.id === over.id) { return; } - const urls = manualShortcuts.map((s) => s.url); + const urls = manager.shortcuts.map((s) => s.url); const oldIndex = urls.indexOf(active.id as string); const newIndex = urls.indexOf(over.id as string); if (oldIndex < 0 || newIndex < 0) { return; } - const overflowUrls = manager.shortcuts - .slice(MAX_SHORTCUTS) - .map((s) => s.url); - manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); - const moved = manualShortcuts[oldIndex]; + manager.reorder(arrayMove(urls, oldIndex, newIndex)); + const moved = manager.shortcuts[oldIndex]; const label = moved?.name || moved?.url || 'Shortcut'; setReorderAnnouncement( `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, @@ -255,9 +235,8 @@ export function ShortcutLinksHub({ const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); - // Chrome-style dismiss for auto mode: hide the tile for this browser and - // offer a single-action "Undo" toast. We can't delete the site from the - // browser's history, so we just remember the URL locally. + // 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; @@ -272,11 +251,8 @@ export function ShortcutLinksHub({ const onAdd = () => openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); - // Drag-a-link-into-the-row shortcut: skip the edit modal entirely when - // the user drops a URL from the address bar, another tab, or the - // browser's bookmarks bar. We only surface a toast when the add fails - // (duplicate / limit), because the success case speaks for itself — the - // tile just appears in the row. + // Dropping a URL from the address bar, another tab, or bookmarks bar + // adds directly without the edit modal. Only toast on failure. const onDropUrl = async (url: string) => { const result = await manager.addShortcut({ url }); if (result.error) { @@ -284,10 +260,6 @@ export function ShortcutLinksHub({ } }; - // The whole toolbar is the drop zone (auto mode excluded — we can't add - // to a browser-managed list). The "+" tile is still visible for click - // discoverability, but users no longer have to aim at a 44px target to - // drop a bookmark — anywhere on the row counts. const canAcceptDroppedUrl = !isAuto && manager.canAdd; const { isDropTarget, dropHandlers } = useShortcutDropZone( onDropUrl, @@ -296,22 +268,12 @@ export function ShortcutLinksHub({ const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); - const requestTopSitesAccess = async () => { - // Unlike the import flow, we only need READ access here — we're not - // copying sites into customLinks, just rendering whatever the browser - // exposes. If the user declines we stay on the empty-state CTA. - const granted = await askTopSitesPermission(); - return granted; - }; - + // 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'); - // Auto mode is worthless without topSites permission — if the user - // declines (or it was previously denied and the browser returned no - // data), flip back to manual so they don't end up with an empty row - // and no idea how to fix it. if (!hasCheckedTopSitesPermission || topSites === undefined) { - const granted = await requestTopSitesAccess(); + const granted = await askTopSitesPermission(); if (!granted) { await updateFlag('shortcutsMode', 'manual'); } @@ -320,10 +282,6 @@ export function ShortcutLinksHub({ const switchToManual = () => updateFlag('shortcutsMode', 'manual'); - // The overflow menu is the same shape in both modes — source selection is - // an inline toggle at the top, so users never see items appear/disappear - // after flipping mode. "Add shortcut" stays visible but is disabled in auto - // so the placement doesn't jump. const toggleSourceMode = () => { if (isAuto) { switchToManual(); @@ -332,11 +290,6 @@ export function ShortcutLinksHub({ } }; - // The inline "+" tile already lets users add when there's room. We don't - // mirror "Add shortcut" into the dropdown: it either duplicates the tile - // (manual + room left) or points at a disabled action (at the 12/12 cap), - // both of which are clutter. At the limit, the library's-full story is - // told by the Manage modal's counter and by tiles already filling the row. const menuOptions = [ { icon: , @@ -350,25 +303,18 @@ export function ShortcutLinksHub({ }, ]; - // Auto mode empty has two shapes: either the user hasn't granted topSites - // permission yet (ask for it) or they've granted it but the browser - // returned no sites (new profile, cleared history). We surface copy for - // both — "Grant access" is wrong when the user already granted and just - // has an empty history. + // 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; - // Controlled open state so the trigger stays visible while the menu is - // open even when the user hovers *into* the floating menu content. const [menuOpen, setMenuOpen] = useState(false); - // Force the trigger visible in these cases so users aren't trapped: - // - the menu is already open (don't yank the trigger mid-hover) - // - the auto-mode empty state is showing (no tiles to hover, only options) - // - there's literally nothing else in the row (no "+" tile, no tiles) + // 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 || @@ -383,25 +329,13 @@ export function ShortcutLinksHub({ onDragStartCapture={suppressNativeDragCapture} {...dropHandlers} className={classNames( - // `group` powers the hover-reveal of the overflow button below. + // `group/hub` powers the hover-reveal of the overflow button. 'group/hub', - // Shown from mobileXL (500px) up so narrow desktop windows and - // split-screen use get the hub too. Still hidden on phone-sized - // viewports where the new-tab page isn't really the use-case. 'hidden flex-wrap items-center mobileXL:flex', - // Gap scales with density: tiles still need a touch of breathing - // room because of the label, but tighter than before so the row - // reads as one cluster of shortcuts. Icons/chips pack like a real - // bookmarks bar. appearance === 'tile' && 'items-start gap-x-1 gap-y-2', appearance === 'icon' && 'gap-1', appearance === 'chip' && 'gap-1', shouldUseListFeedLayout ? 'mx-6 mb-3 mt-1' : 'mb-5', - // Drag-to-add indicator: a soft accent ring + tinted background - // highlights the entire row so users dragging a bookmark see a - // clear "drop here" affordance regardless of where on the row - // they aim. `ring` uses box-shadow so there's no layout shift, - // and `rounded-12` keeps the halo in keeping with the tiles. 'rounded-12 transition-[box-shadow,background-color] duration-150 motion-reduce:transition-none', isDropTarget && 'bg-overlay-float-cabbage ring-2 ring-accent-cabbage-default ring-offset-4 ring-offset-background-default', @@ -443,7 +377,7 @@ export function ShortcutLinksHub({ {showAutoPermissionCta && ( - )} {reorderAnnouncement} @@ -482,23 +400,14 @@ export function ShortcutLinksHub({ icon={} className={classNames( 'ml-1 !size-8 !min-w-0 !rounded-10 text-text-tertiary transition-opacity duration-150 hover:bg-surface-float hover:text-text-primary motion-reduce:transition-none', - // Quiet by default, reveals when the user shows intent: - // - hovering anywhere on the row - // - keyboard-focusing any child (focus-within) - // - touch devices where hover doesn't exist - // - any case flagged above where hiding would trap the user + // Quiet by default, revealed on hover/focus, and forced visible + // when hiding it would trap the user (see forceShowMenuButton). forceShowMenuButton ? 'opacity-100' : 'opacity-0 focus-visible:opacity-100 group-focus-within/hub:opacity-100 group-hover/hub:opacity-100 [@media(hover:none)]:opacity-100', - // Center the button on the favicon row, not the whole tile - // (the tile is tall because of the label underneath). - // tile padding-top: p-2 = 8px - // favicon height: size-11 = 44px → center at 8 + 22 = 30px - // button height: size-8 = 32px → top offset = 30 − 16 = 14px - // `self-start` pins align-self so parent `items-center` can't - // re-center the button on the full tile height and drag it - // back down onto the label row. - appearance === 'tile' && 'mt-[14px] self-start', + // Align with the favicon row, not the whole tile. `self-start` + // keeps parent `items-center` from recentering onto the label. + appearance === 'tile' && 'mt-3.5 self-start', )} aria-label="Shortcut options" /> diff --git a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx index 7c27da0e66a..8dcdd2ecc5c 100644 --- a/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/AddShortcutTile.tsx @@ -9,21 +9,13 @@ interface AddShortcutTileProps { onClick: () => void; appearance?: ShortcutsAppearance; disabled?: boolean; - /** - * Whether the surrounding row is currently accepting a URL drop. Drop - * handling lives on the parent toolbar (see `useShortcutDropZone`) so the - * whole row is one big drop target instead of this small button. The tile - * itself only reflects the state in its label + aria hints so users know - * dropping is an option even when the indicator isn't firing yet. - */ + // Whether the parent row currently accepts a URL drop (drop handling lives + // on the toolbar via `useShortcutDropZone` — this tile just mirrors state + // in its label + aria hints). acceptsDroppedUrl?: boolean; - /** Reflects the parent-owned `isDropTarget` state for the label swap. */ isDropActive?: boolean; } -// Mirrors ShortcutTile's three appearance layouts so the row stays visually -// coherent across modes. Dashed outline on the icon slot signals "empty" -// without competing with the real tiles around it. export function AddShortcutTile({ onClick, appearance = 'tile', @@ -34,8 +26,6 @@ export function AddShortcutTile({ const isChip = appearance === 'chip'; const isIconOnly = appearance === 'icon'; - // While the parent row is highlighted as a drop target, also tint the - // "+" slot so the user's aim has a clear focal point inside the halo. const dropStateClass = isDropActive ? 'border-accent-cabbage-default bg-accent-cabbage-default/10 text-accent-cabbage-default' : ''; @@ -66,7 +56,7 @@ export function AddShortcutTile({ onClick={onClick} disabled={disabled} className={classNames( - 'group flex h-9 max-w-[140px] items-center gap-2 rounded-10 border border-dashed border-border-subtlest-tertiary bg-transparent px-2 text-text-tertiary outline-none transition-colors duration-150 ease-out hover:border-solid hover:border-border-subtlest-secondary hover:bg-surface-float hover:text-text-primary focus-visible:bg-surface-float motion-reduce:transition-none', + 'group flex h-9 max-w-[8.75rem] items-center gap-2 rounded-10 border border-dashed border-border-subtlest-tertiary bg-transparent px-2 text-text-tertiary outline-none transition-colors duration-150 ease-out hover:border-solid hover:border-border-subtlest-secondary hover:bg-surface-float hover:text-text-primary focus-visible:bg-surface-float motion-reduce:transition-none', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent', isDropActive && dropStateClass, )} @@ -106,7 +96,7 @@ export function AddShortcutTile({ onClick={onClick} disabled={disabled} className={classNames( - 'group flex w-[76px] flex-col items-center gap-1.5 rounded-14 p-2 outline-none transition-colors duration-150 ease-out hover:bg-surface-float focus-visible:bg-surface-float motion-reduce:transition-none', + 'group flex w-[4.75rem] flex-col items-center gap-1.5 rounded-14 p-2 outline-none transition-colors duration-150 ease-out hover:bg-surface-float focus-visible:bg-surface-float motion-reduce:transition-none', 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent', )} aria-label={`Add shortcut${dropHint}`} diff --git a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx index d903b3d2840..304bf3c368c 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx @@ -54,12 +54,10 @@ export type ShortcutEditFormProps = { onStateChange?: (state: ShortcutEditFormState) => void; }; -// Reusable form body for adding/editing a single shortcut. Rendered inside a -// standalone modal by `ShortcutEditModal` and inline inside the manage modal -// by `ShortcutsManageModal`. Leaves the action buttons to the parent so each -// host can place them in its own footer/structure — the form just exposes a -// stable `formId` to bind the submit button to and reports submit/upload -// state through `onStateChange` so the parent can disable its submit button. +// 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, @@ -91,9 +89,8 @@ export function ShortcutEditForm({ formState: { isSubmitting }, } = methods; - // Surface submit/upload state to the parent so it can disable its submit - // button. Kept in a ref so we don't re-invoke on every render — we only - // push when the booleans actually flip. + // 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 }; @@ -112,13 +109,8 @@ export function ShortcutEditForm({ const fileInputRef = useRef(null); const [faviconFailed, setFaviconFailed] = useState(false); const [customIconFailed, setCustomIconFailed] = useState(false); - // Drop-on-avatar: users who have a favicon.ico / logo sitting on disk - // should be able to fling it onto the icon picker without clicking - // through a file dialog. Mirrors the AddShortcutTile drop affordance. const [isDropTarget, setIsDropTarget] = useState(false); - // Debounced copy of the URL used solely for the live favicon preview. - // Typing 15 characters shouldn't fire 15 requests to the icon proxy — - // 250ms idle matches the "I stopped typing" feel of most address bars. + // 250ms debounce on favicon requests while the user is typing. const [debouncedUrl, setDebouncedUrl] = useState(shortcut?.url ?? ''); const handleIconBase64 = async (base64: string, file: File) => { @@ -274,7 +266,7 @@ export function ShortcutEditForm({ aria-hidden className="absolute inset-0 flex items-center justify-center bg-overlay-primary-pepper" > - + )} {!isUploading && ( @@ -299,7 +291,7 @@ export function ShortcutEditForm({ />
    {/* eslint-disable-next-line no-nested-ternary */} {isUploading ? ( diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index 75fd2b13a14..e192249245a 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -153,11 +153,10 @@ export function ShortcutTile({ [], ); - // `isDragging` can flip back to false *before* the browser fires the stray - // `click` that follows pointerup on a drag. And because dnd-kit reorders - // tiles under the pointer, that click sometimes lands on a sibling tile - // where `didPointerTravel` has no recorded origin to compare against. A - // short "just dragged" window catches both cases reliably. + // `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(() => { @@ -229,15 +228,9 @@ export function ShortcutTile({ const dragHandleProps = draggable ? { ...attributes, ...listeners } : {}; - // Anchors (``) and images are natively draggable via the browser's - // HTML5 drag-and-drop. With dnd-kit's PointerSensor using a 5px activation - // threshold, the browser can start its own URL-drag before dnd-kit takes - // over. If the user drops that URL outside a registered drop zone — - // anywhere to the *left* of `AddShortcutTile` — Chrome's default action is - // to navigate the current tab to the URL, which looks exactly like a - // stray click. Swallowing `dragstart` at the tile root disables native - // HTML5 drag for the anchor and favicon without affecting dnd-kit (which - // listens to pointer events, not drag events). + // 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(); }, []); @@ -245,12 +238,6 @@ export function ShortcutTile({ const isChip = appearance === 'chip'; const isIconOnly = appearance === 'icon'; - // Favicon/letter renderer, sized per appearance. Chip mode uses a smaller - // 16px glyph to fit the compact pill; tile/icon modes stay at the roomier - // 24px favicon the rest of the feature uses. `draggable={false}` kills - // the browser's default image drag so dnd-kit's pointer lifecycle is the - // only drag semantics on the tile — a stray drop outside the hub can no - // longer hand Chrome a URL to navigate the tab to. const iconContent = shouldShowFavicon ? ( ); - // Anchor (the clickable favicon box). Tile/icon modes make it the whole - // square; chip mode makes it a compact slot inside a horizontal pill. - // `draggable={false}` at the DOM level — belt to the `onDragStart` - // preventDefault suspenders — because Chrome otherwise starts a URL drag - // on mousedown before React's delegated handler can cancel it. + // `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', @@ -277,14 +261,9 @@ export function ShortcutTile({ 'aria-label': label, }; - // `` defaults to `cursor: pointer` which overrides the container's - // `cursor-grab` and makes users think the favicon isn't a drag handle - // (they see a click cursor, try to drag anyway, and the drop lands a - // stray click that navigates the tab). Force the grab/grabbing cursor - // on the anchor too so the whole tile reads as draggable. When drag is - // in-flight, `pointer-events-none` on the anchor keeps the browser from - // firing its `click` on pointerup — the drop becomes a no-op visually - // even if every other suppression fails. + // 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', @@ -292,20 +271,16 @@ export function ShortcutTile({ ) : ''; - // Outer container styling per appearance: - // - tile : 76px-wide column with label underneath (Chrome new tab). - // - icon : compact square (iOS dock / Arc pinned tabs). - // - chip : horizontal pill with favicon + label (Chrome bookmarks bar). let appearanceContainerClass: string; if (isChip) { appearanceContainerClass = - 'flex h-9 max-w-[200px] items-center gap-2 rounded-10 bg-surface-float pl-2 pr-2 focus-within:bg-background-default hover:bg-background-default'; + '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-[76px] flex-col items-center rounded-14 p-2 focus-within:bg-surface-float hover:bg-surface-float'; + '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', @@ -316,9 +291,6 @@ export function ShortcutTile({ className, ); - // Action button sits in the top-right corner of the tile across every - // layout. Chip mode pokes slightly outside to clear the pill edge; tile - // and icon modes tuck just inside the container. let actionBtnPositionClass: string; if (isChip) { actionBtnPositionClass = 'absolute -right-1 -top-1'; diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx index cbf018b0f63..a370916f713 100644 --- a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import classNames from 'classnames'; import { closestCenter, @@ -30,24 +30,17 @@ import { DRAG_ACTIVATION_DISTANCE_PX, } from '../hooks/useDragClickGuard'; import { useShortcutDropZone } from '../hooks/useShortcutDropZone'; -import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../types'; +import { DEFAULT_SHORTCUTS_APPEARANCE } from '../types'; import type { Shortcut, ShortcutsAppearance } from '../types'; interface WebappShortcutsRowProps { className?: string; } -/** - * Webapp-side shortcut row. Only renders when the user has enabled - * `showShortcutsOnWebapp` from the extension's manage modal. Reuses the same - * `ShortcutTile` and `useShortcutsManager` the extension hub does so edits - * and reorders stay in sync across surfaces. - * - * Auto mode (live top-sites from the browser) is intentionally ignored on - * the webapp — we don't have topSites permission outside the extension and - * the "most visited sites" concept doesn't travel across devices anyway. - * Manual curated shortcuts do. - */ +// 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 { @@ -60,15 +53,8 @@ export function WebappShortcutsRow({ const enabled = flags?.showShortcutsOnWebapp ?? false; const appearance: ShortcutsAppearance = flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + const shortcuts = manager.shortcuts; - const shortcuts: Shortcut[] = useMemo( - () => manager.shortcuts.slice(0, MAX_SHORTCUTS), - [manager.shortcuts], - ); - - // One-shot impression per enabled->rendered cycle. Lets us slice hub - // adoption between "on the extension" and "on the webapp" without needing - // client-side duplication. const loggedRef = useRef(false); useEffect(() => { if (loggedRef.current) { @@ -97,19 +83,11 @@ export function WebappShortcutsRow({ }), ); - // Same click-suppression guard the extension hub uses: dnd-kit swallows - // the pointerdown to pointerup sequence but the browser still fires a - // click on release, so we intercept it (otherwise the link would - // navigate mid-drag). The hook attaches a document-level capture-phase - // listener so clicks that land *outside* the toolbar's DOM subtree after - // a long drag are still caught. + // 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(); - // Match the extension hub's native-drag backstop. Tiles already mark their - // anchors/favicons as `draggable={false}`, but capture-phase cancellation - // at the toolbar root kills any stray URL drag before the browser can - // navigate the tab on drop-outside-a-handler. const suppressNativeDragCapture = (event: React.DragEvent) => { event.preventDefault(); }; @@ -126,10 +104,7 @@ export function WebappShortcutsRow({ if (oldIndex < 0 || newIndex < 0) { return; } - const overflowUrls = manager.shortcuts - .slice(MAX_SHORTCUTS) - .map((s) => s.url); - manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); + manager.reorder(arrayMove(urls, oldIndex, newIndex)); }; const onEdit = (shortcut: Shortcut) => @@ -150,18 +125,12 @@ export function WebappShortcutsRow({ } }; - // The whole row is the drop zone, not just the tiny "+" tile — see - // `useShortcutDropZone` for rationale. Only active when there's room in - // the library for another shortcut. const canAcceptDroppedUrl = manager.canAdd; const { isDropTarget, dropHandlers } = useShortcutDropZone( onDropUrl, canAcceptDroppedUrl, ); - // Gatekeeping: only render for opted-in users with something to show or - // the ability to add. Users who haven't turned on the setting — or who - // hid the row entirely — get nothing. if (!enabled || !showTopSites) { return null; } @@ -182,9 +151,6 @@ export function WebappShortcutsRow({ appearance === 'tile' && 'items-start gap-x-1 gap-y-2', appearance === 'icon' && 'gap-1', appearance === 'chip' && 'gap-1', - // Drag-to-add indicator: same treatment as the extension hub so - // the two surfaces feel like the same widget. Ring is box-shadow - // based → no layout shift when the halo turns on. 'rounded-12 transition-[box-shadow,background-color] duration-150 motion-reduce:transition-none', isDropTarget && 'bg-overlay-float-cabbage ring-2 ring-accent-cabbage-default ring-offset-4 ring-offset-background-default', diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx index cecc353e487..025963c7d28 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -22,7 +22,6 @@ import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { useToastNotification } from '../../../../hooks/useToastNotification'; import { useLazyModal } from '../../../../hooks/useLazyModal'; import type { LazyModal } from '../../../../components/modals/common/types'; -import { invokeOnRequestClose } from './closeModal'; export interface ImportPickerItem { url: string; @@ -33,18 +32,13 @@ export interface ImportPickerModalProps extends ModalProps { source: ImportSource; items: ImportPickerItem[]; onImported?: (result: { imported: number; skipped: number }) => void; - // When set, the Cancel button hands control back to this modal instead of - // fully dismissing the stack. Keeps "cancel the import" distinct from - // "close the whole flow" (which the header X still does). Narrowed to - // `ShortcutsManage` because that's the only prop-less modal we reopen - // from here; keeping it narrow avoids the generic `openModal` call - // requiring a `props` argument at the type level. + // 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; } -// Favicon with graceful fallback: the browser-icon proxy often ships a blurry -// 16px globe for sites it doesn't know. Instead of rendering that fuzz, we -// swap to a letter chip painted from the site's first character. +// The icon proxy falls back to a blurry generic globe for unknown sites; +// swap that for a letter chip instead. function FaviconOrLetter({ url, label, @@ -92,12 +86,9 @@ export default function ImportPickerModal({ const close = () => { closeModal(); - invokeOnRequestClose(props.onRequestClose); + props.onRequestClose?.(undefined as never); }; - // Cancel = "back out of the import", not "close the whole shortcuts flow". - // If the picker was triggered from another modal (e.g. Manage), hand - // control back there so the user lands where they came from. const handleCancel = () => { if (returnTo) { openModal({ type: returnTo }); @@ -150,10 +141,9 @@ export default function ImportPickerModal({ 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) { - // Every selected row was a duplicate or we were at capacity — calling - // that a success with "Imported 0" reads like a bug. Tell the user - // what actually happened instead. displayToast( result.skipped > 0 ? `Nothing imported — ${result.skipped} ${noun} already in shortcuts` @@ -171,10 +161,6 @@ export default function ImportPickerModal({ const isBookmarks = source === 'bookmarks'; const title = isBookmarks ? 'Import bookmarks' : 'Import most visited'; - // Spell out where the list came from and how many rows the browser surfaced. - // Stops users assuming we've clipped the list at whatever number they see - // (Chrome's topSites API, for instance, returns however many repeat-visit - // origins the profile has, sometimes 8, sometimes 20). const sourceCopy = isBookmarks ? `Pick the ones you want. Your bookmarks stay untouched. ${items.length} available.` : `Pick the ones you want. Snapshot from your browser. ${ @@ -185,9 +171,6 @@ export default function ImportPickerModal({ return ( - {/* Same header rhythm as the Manage / Edit modals: left-aligned, Body - bold, no oversized Title1. Subtitle lives in the body as helper - copy so the header stays compact. */} {title} @@ -195,10 +178,6 @@ export default function ImportPickerModal({

    {sourceCopy}

    - {/* Calm status strip: what you've picked + how many slots you have - left, and an inline Select-all / Clear-all toggle. No fill bar, - no "progress to fill" metaphor. Picking is optional, not a - task. */}
    {items.length === 0 ? ( - // Empty state worth looking at. The source-specific glyph tells - // users what we tried to read from, and the copy explains *why* - // there's nothing — not just "empty" which reads like our bug.
    ) : ( - // Tap-to-toggle rows. No separate checkbox column. Selected state is - // a check badge on the icon itself (iOS Photos multi-select feel) - // plus a calm surface tint. Dead quiet until you interact.
      toggle(item.url)} className={classNames( - // Selection is carried by the trailing check alone so - // a long list of picked rows doesn't look like a wall - // of colour. Selected rows get a hair of surface tint - // to feel "lifted", nothing more. 'group relative flex w-full items-center gap-3 rounded-12 p-2 text-left transition-colors duration-150 motion-reduce:transition-none', isChecked ? 'bg-surface-float hover:bg-surface-float' @@ -300,8 +269,6 @@ export default function ImportPickerModal({ {getDomainFromUrl(item.url)}

    - {/* The only selection signal: a small filled check on the - trailing edge. Empty ring at rest invites a tap. */} void | Promise; ctaLabel?: string; @@ -28,11 +25,7 @@ export function MostVisitedSitesPermissionContent({ permissions. Once approved, it will be kept locally. { closeModal(); - invokeOnRequestClose(props.onRequestClose); + props.onRequestClose?.(undefined as never); }; return ( diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index 317d272e87b..a41dcb528f7 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -59,13 +59,9 @@ import { apiUrl } from '../../../../lib/config'; import { getDomainFromUrl } from '../../../../lib/links'; import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../../types'; import type { Shortcut, ShortcutsAppearance } from '../../types'; -import { invokeOnRequestClose } from './closeModal'; import type { ShortcutEditFormState } from '../ShortcutEditForm'; import { ShortcutEditForm } from '../ShortcutEditForm'; -// Flattened state-machine for the Browser access row's primary action. The -// button can either trigger the picker (granted) or ask for permission (not -// granted); extracted from JSX so we don't nest ternaries inline. function getTopSitesPrimaryAction({ topSitesGranted, setShowImportSource, @@ -92,7 +88,6 @@ function getTopSitesPrimaryAction({ }; } -// Same flattening as `getTopSitesPrimaryAction`, but for the Bookmarks row. function getBookmarksPrimaryAction({ bookmarksGranted, onImportBookmarks, @@ -113,10 +108,6 @@ function getBookmarksPrimaryAction({ }; } -// Plain-text section header. Bold subhead + muted caption, no decorative -// icon chip. Keeps each group clearly delimited vertically without the -// visual weight of a leading glyph — settings rhythm closer to Linear / -// GitHub preferences than Raycast. function SectionHeader({ title, description, @@ -146,11 +137,6 @@ function SectionHeader({ ); } -// Compact capacity pill used next to "Your shortcuts". The tone warms up as -// the library fills so the limit feels present without ever shouting — grey -// through most of the range, cabbage accent when there are two or fewer -// slots left, rose when the cap is hit. Tabular nums keep the width steady -// as the count ticks up. function CapacityPill({ used, max, @@ -177,14 +163,6 @@ function CapacityPill({ ); } -// Clean radio row. Selected state is carried entirely by the filled cabbage -// dot + bold title — no background fill, so it never reads like a hover. -// Hover is the only place we tint the surface, which keeps the difference -// between "you're pointing at this" and "this is selected" obvious. An -// optional `trailingBadge` sits on the right (kept out of the radio/text -// column) so we can flag a row with a brand mark — e.g. the Chrome glyph -// on the auto-mode row — without knocking the radio bullet and copy out -// of alignment. function ShortcutsModeOption({ id, checked, @@ -279,9 +257,6 @@ function ShortcutRow({ ref={setNodeRef} style={style} className={classNames( - // Rows are quiet by default; hover darkens the surface only. - // Drag state tilts slightly with a real shadow so the user feels - // the row "lift" without the busy scale-up jump. 'group relative flex items-center gap-3 rounded-10 p-2 transition-colors duration-150 hover:bg-surface-float motion-reduce:transition-none', isDragging && 'z-10 rotate-[-1deg] bg-surface-float shadow-2 motion-reduce:rotate-0', @@ -307,9 +282,8 @@ function ShortcutRow({ {shortcut.url}

    - {/* Actions fade in on row hover/focus. On touch devices (no hover), - we reveal them at 60% opacity so they're always reachable without - overwhelming the row. */} + {/* Row actions are hover-revealed on pointer devices and always + partially visible on touch (no hover state to reveal them). */}
    - {/* Sections stack vertically with hairline dividers. Flow goes: - visibility (master switch) → look → source (with inline auto - controls when auto is picked) → your list (manual) → - connections (bookmarks + cross-device sync). */}
    )} - {/* Auto-mode connections. Mirrors the manual-mode Connections - section 1:1 — same SectionHeader treatment, same bare
      - of ConnectionRows — so the divider, title, and row padding - all line up regardless of which source the user picks. */} {showTopSites && mode === 'auto' && (
      ) : (
      - {/* Inline "Add" affordance sitting above the list. At - the cap we keep it visible but disabled with a tiny - "Library full" hint so users know why they can't add. */} + + + ), + }; + }; useEffect(() => { if (!showImportSource) { @@ -47,155 +124,70 @@ export function ShortcutImportFlow(): ReactElement | null { return; } - const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); - - if (showImportSource === 'topSites') { - if (!hasCheckedTopSitesPermission || topSites === undefined) { - return; - } - if (handledRef.current === 'topSites') { - return; - } - handledRef.current = 'topSites'; - - if (topSites.length === 0) { - displayToast('No top sites yet. Visit some sites and try again.'); - setShowImportSource?.(null); - return; - } - if (capacity === 0) { - displayToast( - `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, - ); - setShowImportSource?.(null); - return; - } - - // Always show the picker so the user sees exactly what gets imported, - // which source it comes from, and can deselect items before confirming. - // Previously we silently imported when items fit in capacity, which was - // confusing ("what just got added? where from?"). - const items = topSites.map((s) => ({ url: s.url })); - openModal({ - type: LazyModal.ImportPicker, - props: { source: 'topSites', items, returnTo: returnToAfterImport }, - }); - setShowImportSource?.(null); + const importState = getImportState(showImportSource); + if (!importState.hasCheckedPermission || importState.items === undefined) { return; } - if (showImportSource === 'bookmarks') { - if (!hasCheckedBookmarksPermission || bookmarks === undefined) { - return; - } - if (handledRef.current === 'bookmarks') { - return; - } - handledRef.current = 'bookmarks'; + if (handledRef.current === showImportSource) { + return; + } + handledRef.current = showImportSource; - if (bookmarks.length === 0) { - displayToast( - 'Your bookmarks bar is empty. Add some bookmarks and try again.', - ); - setShowImportSource?.(null); - return; - } - if (capacity === 0) { - displayToast( - `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, - ); - setShowImportSource?.(null); - return; - } + const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); + if (importState.items.length === 0) { + displayToast(importState.emptyToast); + closeImportFlow(); + return; + } - const items = bookmarks.map((b) => ({ url: b.url, title: b.title })); - openModal({ - type: LazyModal.ImportPicker, - props: { source: 'bookmarks', items, returnTo: returnToAfterImport }, - }); - setShowImportSource?.(null); + 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: importState.items, + returnTo: returnToAfterImport, + }, + }); + closeImportFlow(); }, [ - showImportSource, - topSites, - hasCheckedTopSitesPermission, + askBookmarksPermission, + askTopSitesPermission, bookmarks, - hasCheckedBookmarksPermission, customLinks, displayToast, + hasCheckedBookmarksPermission, + hasCheckedTopSitesPermission, openModal, - setShowImportSource, returnToAfterImport, + setShowImportSource, + showImportSource, + topSites, ]); - // Permission modals: shown when the user asked to import but the browser - // hasn't granted permission yet. Once granted, the provider refreshes - // `topSites` / `bookmarks` and the effect above finishes the import. - if ( - showImportSource === 'topSites' && - hasCheckedTopSitesPermission && - topSites === undefined - ) { - const onGrant = async () => { - const granted = await askTopSitesPermission(); - if (!granted) { - setShowImportSource?.(null); - } - }; - return ( - setShowImportSource?.(null)} - > - - - Show most visited sites - - - - - ); + if (!showImportSource) { + return null; } - if ( - showImportSource === 'bookmarks' && - hasCheckedBookmarksPermission && - bookmarks === undefined - ) { - const onGrant = async () => { - const granted = await askBookmarksPermission(); - if (!granted) { - setShowImportSource?.(null); - } - }; - return ( - setShowImportSource?.(null)} - > - - - Import your bookmarks bar - - To import your bookmarks bar, your browser will ask for permission - to read bookmarks. We never sync your bookmarks to our servers. - - - - - - - ); + const importState = getImportState(showImportSource); + if (!importState.hasCheckedPermission || importState.items !== undefined) { + return null; } - return null; + const handleGrant = async () => { + const granted = await importState.askPermission(); + if (!granted) { + closeImportFlow(); + } + }; + + return importState.renderPermissionModal(handleGrant, closeImportFlow); } diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index ffc0950685e..0bd68649ac9 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -13,11 +13,9 @@ import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { useShortcutLinks } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutLinks'; -import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; -import { featureShortcutsHub } from '@dailydotdev/shared/src/lib/featureManagement'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; +import { useIsShortcutsHubEnabled } from '@dailydotdev/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled'; import { ShortcutLinksList } from './ShortcutLinksList'; import { ShortcutGetStarted } from './ShortcutGetStarted'; import { ShortcutLinksHub } from './ShortcutLinksHub'; @@ -172,13 +170,9 @@ function NewShortcutLinks({ } export default function ShortcutLinks(props: ShortcutLinksProps): ReactElement { - const { user } = useAuthContext(); - const { value: hubEnabled } = useConditionalFeature({ - feature: featureShortcutsHub, - shouldEvaluate: !!user, - }); + const hubEnabled = useIsShortcutsHubEnabled(); - if (user && hubEnabled) { + if (hubEnabled) { return ; } diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx index f04612db012..b401ae80cc2 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -11,42 +11,19 @@ import { } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { - arrayMove, horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuOptions, - DropdownMenuTrigger, -} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; -import { - EyeIcon, - MenuIcon, - SettingsIcon, -} from '@dailydotdev/shared/src/components/icons'; -import { ChromeIcon } from '@dailydotdev/shared/src/components/icons/Browser/Chrome'; -import { MenuIcon as WrappingMenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { ShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/ShortcutTile'; import { AddShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/AddShortcutTile'; -import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; import { useHiddenTopSites } from '@dailydotdev/shared/src/features/shortcuts/hooks/useHiddenTopSites'; import { useDragClickGuard, DRAG_ACTIVATION_DISTANCE_PX, } from '@dailydotdev/shared/src/features/shortcuts/hooks/useDragClickGuard'; -import { useShortcutDropZone } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutDropZone'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; @@ -65,49 +42,14 @@ 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; } -interface SourceModeToggleItemProps { - isAuto: boolean; - onToggle: () => void; -} - -// `preventDefault` on `onSelect` keeps the menu open after toggling — this -// is a setting, not an action. Switch is pointer-events-none so the click -// lands on the DropdownMenuItem row, not the native input. -function SourceModeToggleItem({ - isAuto, - onToggle, -}: SourceModeToggleItemProps): ReactElement { - return ( - { - event.preventDefault(); - onToggle(); - }} - > - - - Most visited sites - - - - ); -} - export function ShortcutLinksHub({ shouldUseListFeedLayout, }: ShortcutLinksHubProps): ReactElement { @@ -116,7 +58,7 @@ export function ShortcutLinksHub({ useSettingsContext(); const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); - const manager = useShortcutsManager(); + const manualRow = useManualShortcutsRow(); const { hidden: hiddenTopSites, hide: hideTopSite, @@ -132,6 +74,9 @@ export function ShortcutLinksHub({ // 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; @@ -169,13 +114,9 @@ export function ShortcutLinksHub({ logEvent({ event_name: LogEvent.Impression, target_type: TargetType.Shortcuts, - extra: JSON.stringify({ - source: isAuto - ? ShortcutsSourceType.Browser - : ShortcutsSourceType.Custom, - }), + extra: JSON.stringify({ source: shortcutSource }), }); - }, [logEvent, showTopSites, mode, isAuto]); + }, [logEvent, showTopSites, mode, shortcutSource]); const [reorderAnnouncement, setReorderAnnouncement] = useState(''); @@ -191,7 +132,7 @@ export function ShortcutLinksHub({ .map((site) => ({ url: site.url, name: site.title || undefined })), [topSites, hiddenTopSitesSet], ); - const visibleShortcuts = isAuto ? autoShortcuts : manager.shortcuts; + const visibleShortcuts = isAuto ? autoShortcuts : manualRow.shortcuts; const handleDragEnd = (event: DragEndEvent) => { armDragSuppression(); @@ -202,17 +143,18 @@ export function ShortcutLinksHub({ if (!over || active.id === over.id) { return; } - const urls = manager.shortcuts.map((s) => s.url); - const oldIndex = urls.indexOf(active.id as string); - const newIndex = urls.indexOf(over.id as string); - if (oldIndex < 0 || newIndex < 0) { + const moved = manualRow.reorderShortcuts( + active.id as string, + over.id as string, + ); + if (!moved) { return; } - manager.reorder(arrayMove(urls, oldIndex, newIndex)); - const moved = manager.shortcuts[oldIndex]; const label = moved?.name || moved?.url || 'Shortcut'; setReorderAnnouncement( - `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, + `Moved ${label} to position ${ + visibleShortcuts.findIndex((shortcut) => shortcut.url === over.id) + 1 + } of ${manualRow.shortcuts.length}`, ); }; @@ -220,21 +162,9 @@ export function ShortcutLinksHub({ logEvent({ event_name: LogEvent.Click, target_type: TargetType.Shortcuts, - extra: JSON.stringify({ - source: isAuto - ? ShortcutsSourceType.Browser - : ShortcutsSourceType.Custom, - }), + extra: JSON.stringify({ source: shortcutSource }), }); - const onEdit = (shortcut: Shortcut) => - openModal({ - type: LazyModal.ShortcutEdit, - props: { mode: 'edit', shortcut }, - }); - - const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); - // 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) => { @@ -248,24 +178,6 @@ export function ShortcutLinksHub({ }); }; - const onAdd = () => - openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); - - // Dropping a URL from the address bar, another tab, or bookmarks bar - // adds directly without the edit modal. Only toast on failure. - const onDropUrl = async (url: string) => { - const result = await manager.addShortcut({ url }); - if (result.error) { - displayToast(result.error); - } - }; - - const canAcceptDroppedUrl = !isAuto && manager.canAdd; - const { isDropTarget, dropHandlers } = useShortcutDropZone( - onDropUrl, - canAcceptDroppedUrl, - ); - const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); // If permission is declined (or revoked since last boot), flip back to @@ -290,19 +202,6 @@ export function ShortcutLinksHub({ } }; - const menuOptions = [ - { - icon: , - label: 'Manage shortcuts…', - action: onManage, - }, - { - icon: , - label: 'Hide shortcuts', - action: toggleShowTopSites, - }, - ]; - // 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 = @@ -318,7 +217,7 @@ export function ShortcutLinksHub({ const forceShowMenuButton = menuOpen || showAutoEmptyState || - (visibleShortcuts.length === 0 && (isAuto || !manager.canAdd)); + (visibleShortcuts.length === 0 && (isAuto || !manualRow.canAdd)); return (
      @@ -359,68 +259,39 @@ export function ShortcutLinksHub({ appearance={appearance} onClick={onLinkClick} draggable={!isAuto} - onEdit={isAuto ? undefined : onEdit} - onRemove={isAuto ? onHideTopSite : onRemove} + onEdit={isAuto ? undefined : manualRow.onEdit} + onRemove={isAuto ? onHideTopSite : manualRow.onRemove} removeLabel={isAuto ? 'Hide' : 'Remove'} /> ))} - {!isAuto && manager.canAdd && ( + {!isAuto && manualRow.canAdd && ( )} - {showAutoPermissionCta && ( - - )} - {showAutoNoHistoryMessage && ( - - Nothing visited yet — check back after browsing a few sites - - )} + {reorderAnnouncement} - - - + )} + {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 ( + + +
      - {items.length === 0 ? ( + {dedupedItems.length === 0 ? (
      - {items.map((item) => { + {dedupedItems.map((item) => { const isChecked = !!checked[item.url]; const atCap = !isChecked && atCapacity; const label = item.title || getDomainFromUrl(item.url); 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..e0fee981ec2 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx @@ -0,0 +1,330 @@ +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={} + /> +
      +
      + ); +} + +function getConnectionPrimaryAction({ + isConnected, + onConnectedAction, + onConnectAction, +}: { + isConnected: boolean; + onConnectedAction?: () => void; + onConnectAction?: () => void | Promise; +}): (() => void) | undefined { + if (isConnected) { + return onConnectedAction; + } + + if (!onConnectAction) { + return undefined; + } + + return () => { + onConnectAction(); + }; +} + +interface ConnectionRowProps { + icon: ReactElement; + label: string; + description: string; + primaryLabel?: string; + onPrimary?: () => void; + secondaryLabel?: string; + onSecondary?: () => void; + 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={getConnectionPrimaryAction({ + isConnected: topSitesGranted, + onConnectedAction: onImportTopSites, + onConnectAction: 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={getConnectionPrimaryAction({ + isConnected: bookmarksGranted, + onConnectedAction: onImportBookmarks, + onConnectAction: 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 index a41dcb528f7..af6ce32ac87 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -1,433 +1,41 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } 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 { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; +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, - TypographyColor, TypographyTag, TypographyType, } from '../../../../components/typography/Typography'; -import { Switch } from '../../../../components/fields/Switch'; -import { - BookmarkIcon, - DragIcon, - EarthIcon, - EditIcon, - LinkIcon, - PlusIcon, - RefreshIcon, - StarIcon, - TrashIcon, - VIcon, -} from '../../../../components/icons'; -import { ChromeIcon } from '../../../../components/icons/Browser/Chrome'; import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { useLogContext } from '../../../../contexts/LogContext'; -import { LogEvent, TargetType } from '../../../../lib/log'; -import { useShortcutsManager } from '../../hooks/useShortcutsManager'; -import { useHiddenTopSites } from '../../hooks/useHiddenTopSites'; -import { useShortcuts } from '../../contexts/ShortcutsProvider'; import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LogEvent, TargetType } from '../../../../lib/log'; import { LazyModal } from '../../../../components/modals/common/types'; -import { apiUrl } from '../../../../lib/config'; -import { getDomainFromUrl } from '../../../../lib/links'; -import { DEFAULT_SHORTCUTS_APPEARANCE, MAX_SHORTCUTS } from '../../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 type { ShortcutEditFormState } from '../ShortcutEditForm'; -import { ShortcutEditForm } from '../ShortcutEditForm'; - -function getTopSitesPrimaryAction({ - topSitesGranted, - setShowImportSource, - askTopSitesPermission, -}: { - topSitesGranted: boolean; - setShowImportSource?: ( - source: 'topSites' | 'bookmarks', - returnTo?: LazyModal.ShortcutsManage, - ) => void; - askTopSitesPermission?: () => Promise | void; -}): (() => void) | undefined { - if (topSitesGranted) { - if (!setShowImportSource) { - return undefined; - } - return () => setShowImportSource('topSites', LazyModal.ShortcutsManage); - } - if (!askTopSitesPermission) { - return undefined; - } - return () => { - askTopSitesPermission(); - }; -} - -function getBookmarksPrimaryAction({ - bookmarksGranted, - onImportBookmarks, - onAskBookmarks, -}: { - bookmarksGranted: boolean; - onImportBookmarks?: () => void; - onAskBookmarks?: () => Promise | void; -}): (() => void) | undefined { - if (bookmarksGranted) { - return onImportBookmarks; - } - if (!onAskBookmarks) { - return undefined; - } - return () => { - onAskBookmarks(); - }; -} - -function SectionHeader({ - title, - description, - trailing, -}: { - title: string; - description?: string; - trailing?: ReactElement; -}): ReactElement { - return ( -
      -
      - - {title} - - {description && ( - - {description} - - )} -
      - {trailing} -
      - ); -} - -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 ShortcutsModeOption({ - id, - checked, - onSelect, - title, - description, - trailingBadge, -}: { - id: string; - checked: boolean; - onSelect: () => void; - title: string; - description: string; - trailingBadge?: ReactElement; -}): ReactElement { - return ( - - ); -} - -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 style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const label = shortcut.name || getDomainFromUrl(shortcut.url); - - return ( -
      - - -
      -

      {label}

      -

      - {shortcut.url} -

      -
      - {/* Row actions are hover-revealed on pointer devices and always - partially visible on touch (no hover state to reveal them). */} -
      -
      -
      - ); -} - -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((opt) => { - const checked = value === opt.id; - return ( - - ); - })} -
      -
      - ); -} +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(); @@ -448,163 +56,104 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { 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 closeEditor = () => setEditing(null); + const openEditor = (next: ShortcutsManageEditingState) => setEditing(next); + const mode = flags?.shortcutsMode ?? 'manual'; - const selectMode = async (next: 'manual' | 'auto') => { + 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); - logEvent({ - event_name: LogEvent.ChangeShortcutsMode, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ mode: next }), - }); + logShortcutsEvent(LogEvent.ChangeShortcutsMode, { mode: next }); + if (next === 'auto' && topSites === undefined) { await askTopSitesPermission(); } }; - const appearance: ShortcutsAppearance = - flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; - const selectAppearance = (next: ShortcutsAppearance) => { + const handleSelectAppearance = (next: ShortcutsAppearance) => { if (next === appearance) { return; } + updateFlag('shortcutsAppearance', next); - logEvent({ - event_name: LogEvent.ChangeShortcutsAppearance, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ appearance: next }), + logShortcutsEvent(LogEvent.ChangeShortcutsAppearance, { + appearance: next, }); }; - const showOnWebapp = flags?.showShortcutsOnWebapp ?? false; - const toggleShowOnWebapp = () => { + const handleToggleShowOnWebapp = () => { const next = !showOnWebapp; updateFlag('showShortcutsOnWebapp', next); - logEvent({ - event_name: LogEvent.ToggleShortcutsOnWebapp, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ enabled: next }), - }); + logShortcutsEvent(LogEvent.ToggleShortcutsOnWebapp, { enabled: next }); }; - const topSitesCount = topSites?.length ?? 0; - const bookmarksCount = bookmarks?.length ?? 0; - const topSitesGranted = topSites !== undefined; - const topSitesKnown = hasCheckedTopSitesPermission && topSitesGranted; - const bookmarksKnown = - hasCheckedBookmarksPermission && bookmarks !== undefined; - - const logRef = useRef(); - logRef.current = logEvent; - - useEffect(() => { - logRef.current?.({ - event_name: LogEvent.OpenShortcutConfig, - target_type: TargetType.Shortcuts, - }); - }, []); + const handleReorderShortcuts = (activeId: string, overId: string) => { + const urls = manager.shortcuts.map((shortcut) => shortcut.url); + const oldIndex = urls.indexOf(activeId); + const newIndex = urls.indexOf(overId); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 5 }, - }), - useSensor(TouchSensor, { - activationConstraint: { delay: 250, tolerance: 5 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) { + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) { return; } - const urls = manager.shortcuts.map((s) => s.url); - const oldIndex = urls.indexOf(active.id as string); - const newIndex = urls.indexOf(over.id as string); + manager.reorder(arrayMove(urls, oldIndex, newIndex)); }; - // Inline add/edit instead of a nested ShortcutEdit modal — the LazyModal - // registry only keeps one modal open at a time, so stacking would close - // Manage and strand the user on an empty surface after save. - const [editing, setEditing] = useState< - { mode: 'add' } | { mode: 'edit'; shortcut: Shortcut } | null - >(null); - const [formState, setFormState] = useState({ - isSubmitting: false, - isUploading: false, - }); - - const onEdit = (shortcut: Shortcut) => setEditing({ mode: 'edit', shortcut }); + const handleEditShortcut = (shortcut: Shortcut) => + openEditor({ mode: 'edit', shortcut }); + const handleAddShortcut = () => openEditor({ mode: 'add' }); - const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + const openTopSitesImport = setShowImportSource + ? () => setShowImportSource('topSites', LazyModal.ShortcutsManage) + : undefined; - const onAdd = () => setEditing({ mode: 'add' }); - - const closeEditor = () => setEditing(null); - - const EDIT_FORM_ID = 'shortcut-edit-form-manage'; + const openBookmarksImport = setShowImportSource + ? () => setShowImportSource('bookmarks', LazyModal.ShortcutsManage) + : undefined; if (editing) { return ( - - - - {editing.mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} - - - - - -
      - - -
      -
      -
      + /> ); } @@ -642,179 +191,44 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { /> {showTopSites && ( -
      - - - -
      - selectMode('manual')} - title="My shortcuts" - description="Curated by you. Add, edit, and reorder." - /> - selectMode('auto')} - title="Most visited sites" - description="Pulled automatically from your browser history." - trailingBadge={} - /> -
      -
      + )} {showTopSites && mode === 'auto' && ( -
      - -
        - } - 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={getTopSitesPrimaryAction({ - topSitesGranted, - setShowImportSource, - askTopSitesPermission, - })} - secondaryLabel={topSitesGranted ? 'Disconnect' : undefined} - onSecondary={ - topSitesGranted ? () => onRevokePermission?.() : undefined - } - /> - {hiddenTopSites.length > 0 && ( - } - label={`Hidden sites (${hiddenTopSites.length})`} - description="Restore sites you removed from your Most visited row." - primaryLabel="Restore all" - onPrimary={() => restoreHiddenTopSites()} - /> - )} -
      -
      + )} {mode === 'manual' && ( -
      - - } - /> - {manager.shortcuts.length === 0 ? ( -
      - - - - - Your shortcuts, your rules - - - Add one manually or import from Connections below. - - -
      - ) : ( -
      - - - s.url)} - strategy={verticalListSortingStrategy} - > - {manager.shortcuts.map((shortcut) => ( - - ))} - - -
      - )} -
      + manager.removeShortcut(shortcut.url)} + onReorder={handleReorderShortcuts} + /> )} - {/* Bookmarks import + webapp sync are manual-only — in auto mode - the row comes straight from browser history. */} {mode === 'manual' && ( - setShowImportSource( - 'bookmarks', - LazyModal.ShortcutsManage, - ) - : undefined - } + onToggleShowOnWebapp={handleToggleShowOnWebapp} + onImportBookmarks={openBookmarksImport} onAskBookmarks={askBookmarksPermission} onRevokeBookmarks={revokeBookmarksPermission} /> @@ -824,7 +238,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement {
      )} @@ -833,135 +247,3 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { ); } - -interface BrowserConnectionsSectionProps { - bookmarksGranted: boolean; - bookmarksCount: number; - bookmarksKnown: boolean; - showOnWebapp: boolean; - onToggleShowOnWebapp: () => void; - onImportBookmarks?: () => void; - onAskBookmarks?: () => void | Promise; - onRevokeBookmarks?: () => void | Promise; -} - -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={getBookmarksPrimaryAction({ - 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={ - - } - /> -
      -
      - ); -} - -interface ConnectionRowProps { - icon: ReactElement; - label: string; - description: string; - primaryLabel?: string; - onPrimary?: () => void; - secondaryLabel?: string; - onSecondary?: () => void; - // When set, replaces the primary/secondary button pair (used by the - // webapp-sync row to drop in a Switch). - trailing?: ReactElement; -} - -function ConnectionRow({ - icon, - label, - description, - primaryLabel, - onPrimary, - secondaryLabel, - onSecondary, - trailing, -}: ConnectionRowProps): ReactElement { - return ( -
    • - - {icon} - -
      -

      {label}

      -

      - {description} -

      -
      -
      - {trailing ?? ( - <> - {secondaryLabel && ( - - )} - {primaryLabel && ( - - )} - - )} -
      -
    • - ); -} 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, + }; +} From b38198c7f8d884b619ee42bc3a430e68fa896bad Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Apr 2026 16:07:37 +0200 Subject: [PATCH 30/32] fix: cleanup part 4 --- .../ShortcutLinks/ShortcutImportFlow.tsx | 121 +++++++++++------- .../shortcuts/components/ShortcutEditForm.tsx | 60 ++++----- .../shortcuts/components/ShortcutTile.tsx | 42 ++++-- .../shortcuts/hooks/useShortcutsManager.ts | 56 +++----- .../shared/src/features/shortcuts/types.ts | 19 --- 5 files changed, 149 insertions(+), 149 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx index 2c699485d64..00682f248d0 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -28,10 +28,63 @@ interface ImportFlowState { items?: ImportPickerItem[]; askPermission: () => Promise; emptyToast: string; - renderPermissionModal: ( - onGrant: () => Promise, - onClose: () => void, - ) => ReactElement; +} + +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 { @@ -60,24 +113,6 @@ export function ShortcutImportFlow(): ReactElement | null { items: topSites?.map((site) => ({ url: site.url })), askPermission: askTopSitesPermission, emptyToast: 'No top sites yet. Visit some sites and try again.', - renderPermissionModal: (onGrant, onClose) => ( - - - - Show most visited sites - - - - - ), }; } @@ -89,32 +124,6 @@ export function ShortcutImportFlow(): ReactElement | null { })), askPermission: askBookmarksPermission, emptyToast: 'Your bookmarks bar is empty. Add some bookmarks and try again.', - renderPermissionModal: (onGrant, onClose) => ( - - - - 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. - - - - - - - ), }; }; @@ -189,5 +198,19 @@ export function ShortcutImportFlow(): ReactElement | null { } }; - return importState.renderPermissionModal(handleGrant, closeImportFlow); + if (showImportSource === 'topSites') { + return ( + + ); + } + + return ( + + ); } diff --git a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx index 304bf3c368c..b43df0e6bd7 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx @@ -9,10 +9,14 @@ import { useShortcutsManager } from '../hooks/useShortcutsManager'; import type { Shortcut } from '../types'; import { isValidHttpUrl, withHttps } from '../../../lib/links'; import { CameraIcon, EarthIcon } from '../../../components/icons'; -import { imageSizeLimitMB, uploadContentImage } from '../../../graphql/posts'; +import { apiUrl } from '../../../lib/config'; +import { + allowedContentImage, + imageSizeLimitMB, + uploadContentImage, +} from '../../../graphql/posts'; import { useFileInput } from '../../../hooks/utils/useFileInput'; import { useToastNotification } from '../../../hooks/useToastNotification'; -import { apiUrl } from '../../../lib/config'; const schema = z.object({ name: z @@ -31,10 +35,7 @@ const schema = z.object({ .string() .optional() .refine( - (value) => - !value || - value.startsWith('data:image/') || - isValidHttpUrl(withHttps(value)), + (value) => !value || isValidHttpUrl(withHttps(value)), 'Must be a valid URL', ), }); @@ -117,6 +118,7 @@ export function ShortcutEditForm({ clearErrors('iconUrl'); setValue('iconUrl', base64, { shouldDirty: true }); setIsUploading(true); + try { const uploadedUrl = await uploadContentImage(file); setValue('iconUrl', uploadedUrl, { shouldDirty: true }); @@ -132,6 +134,7 @@ export function ShortcutEditForm({ const { onFileChange } = useFileInput({ limitMb: imageSizeLimitMB, + acceptedTypes: allowedContentImage, onChange: handleIconBase64, }); @@ -169,12 +172,15 @@ export function ShortcutEditForm({ 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); @@ -281,7 +287,7 @@ export function ShortcutEditForm({ { onFileChange(event.target.files?.[0] ?? null); @@ -293,38 +299,34 @@ export function ShortcutEditForm({ aria-live="polite" className="flex min-h-[1.125rem] flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-center text-text-tertiary typo-caption1" > - {/* eslint-disable-next-line no-nested-ternary */} {isUploading ? ( Uploading… + ) : hasCustomIcon ? ( + + ) : customIconFailed ? ( + + Couldn't load that image. Showing favicon instead. + ) : isDropTarget ? ( Drop to use this image ) : ( <> - {/* eslint-disable-next-line no-nested-ternary */} - {hasCustomIcon ? ( - - ) : customIconFailed ? ( - - Couldn't load that image. Showing favicon instead. - - ) : ( - - {faviconSrc - ? 'Tap or drop to upload' - : 'Tap or drop an image to upload'} - - )} + + {faviconSrc + ? 'Tap or drop to upload' + : 'Tap or drop an image to upload'} + · @@ -333,7 +335,7 @@ export function ShortcutEditForm({ onClick={() => setShowUrlInput((prev) => !prev)} className="underline-offset-2 hover:text-text-primary hover:underline" > - {showUrlInput ? 'Hide icon URL' : 'Paste image URL'} + {showUrlInput ? 'Hide image URL' : 'Paste image URL'} )} diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx index e192249245a..2be380ca966 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -30,7 +30,7 @@ import { DRAG_ACTIVATION_DISTANCE_SQ_PX, POST_DRAG_SUPPRESSION_MS, } from '../hooks/useDragClickGuard'; -import type { Shortcut, ShortcutColor, ShortcutsAppearance } from '../types'; +import type { Shortcut, ShortcutsAppearance } from '../types'; const pixelRatio = typeof globalThis?.window === 'undefined' @@ -38,24 +38,40 @@ const pixelRatio = : globalThis.window.devicePixelRatio ?? 1; const iconSize = Math.round(24 * pixelRatio); -const colorClass: Record = { - burger: 'bg-accent-burger-bolder text-white', - cheese: 'bg-accent-cheese-bolder text-black', - avocado: 'bg-accent-avocado-bolder text-white', - bacon: 'bg-accent-bacon-bolder text-white', - blueCheese: 'bg-accent-blueCheese-bolder text-white', - cabbage: 'bg-accent-cabbage-bolder text-white', +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; - color: ShortcutColor; + seed: string; size?: 'sm' | 'md' | 'lg'; } function LetterChip({ name, - color, + seed, size = 'md', }: LetterChipProps): ReactElement { const letter = (name || '?').charAt(0).toUpperCase(); @@ -70,7 +86,7 @@ function LetterChip({ aria-hidden className={classNames( 'flex items-center justify-center rounded-8 font-bold', - colorClass[color], + getLetterChipClass(seed), sizeClass, )} > @@ -100,7 +116,7 @@ export function ShortcutTile({ removeLabel = 'Remove', className, }: ShortcutTileProps): ReactElement { - const { url, name, iconUrl, color = 'burger' } = shortcut; + const { url, name, iconUrl } = shortcut; const label = name || getDomainFromUrl(url); const [iconBroken, setIconBroken] = useState(false); @@ -247,7 +263,7 @@ export function ShortcutTile({ className={classNames('rounded-4', isChip ? 'size-5' : 'size-6')} /> ) : ( - + ); // `draggable={false}` belt to the `onDragStart` preventDefault suspenders diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts index da34c03c3e7..f213c3a499e 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts @@ -6,31 +6,9 @@ import { LogEvent, ShortcutsSourceType, TargetType } from '../../../lib/log'; import { canonicalShortcutUrl, withHttps } from '../../../lib/links'; import type { SettingsFlags } from '../../../graphql/settings'; import type { ImportSource, Shortcut, ShortcutMeta } from '../types'; -import { MAX_SHORTCUTS, UNDO_TIMEOUT_MS, shortcutColorPalette } from '../types'; +import { MAX_SHORTCUTS, UNDO_TIMEOUT_MS } from '../types'; import { useShortcuts } from '../contexts/ShortcutsProvider'; -// djb2-style string hash; bitwise ops are deliberate for 32-bit truncation -// behaviour equivalent to what Java's String.hashCode() does. -const hashString = (str: string): number => { - let hash = 0; - for (let i = 0; i < str.length; i += 1) { - // eslint-disable-next-line no-bitwise - hash = (hash << 5) - hash + str.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash |= 0; - } - return Math.abs(hash); -}; - -const defaultColorForUrl = (url: string) => { - try { - const host = new URL(withHttps(url)).hostname; - return shortcutColorPalette[hashString(host) % shortcutColorPalette.length]; - } catch (_) { - return shortcutColorPalette[0]; - } -}; - export interface UseShortcutsManager { shortcuts: Shortcut[]; canAdd: boolean; @@ -38,11 +16,10 @@ export interface UseShortcutsManager { url: string; name?: string; iconUrl?: string; - color?: string; }) => Promise<{ error?: string }>; updateShortcut: ( url: string, - patch: { url?: string; name?: string; iconUrl?: string; color?: string }, + patch: { url?: string; name?: string; iconUrl?: string }, ) => Promise<{ error?: string }>; removeShortcut: (url: string) => Promise; reorder: (nextUrls: string[]) => Promise; @@ -53,9 +30,6 @@ export interface UseShortcutsManager { findDuplicate: (url: string) => string | null; } -const colorIsValid = (color?: string): color is ShortcutMeta['color'] => - !!color && (shortcutColorPalette as readonly string[]).includes(color); - export const useShortcutsManager = (): UseShortcutsManager => { const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); @@ -63,7 +37,19 @@ export const useShortcutsManager = (): UseShortcutsManager => { useSettingsContext(); const { setShowImportSource } = useShortcuts(); - const metaMap = useMemo(() => flags?.shortcutMeta ?? {}, [flags]); + 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 @@ -84,7 +70,6 @@ export const useShortcutsManager = (): UseShortcutsManager => { url, name: meta.name, iconUrl: meta.iconUrl, - color: meta.color ?? defaultColorForUrl(url), }; }), [links, metaMap], @@ -138,7 +123,7 @@ export const useShortcutsManager = (): UseShortcutsManager => { ); const addShortcut: UseShortcutsManager['addShortcut'] = useCallback( - async ({ url, name, iconUrl, color }) => { + async ({ url, name, iconUrl }) => { if (!canAdd) { return { error: `You can only add up to ${MAX_SHORTCUTS} shortcuts.` }; } @@ -155,9 +140,6 @@ export const useShortcutsManager = (): UseShortcutsManager => { if (iconUrl) { meta.iconUrl = iconUrl; } - if (colorIsValid(color)) { - meta.color = color; - } const nextLinks = [...links, httpsUrl]; const nextMeta = { ...metaMap }; if (Object.keys(meta).length) { @@ -196,15 +178,11 @@ export const useShortcutsManager = (): UseShortcutsManager => { ...(patch.iconUrl !== undefined ? { iconUrl: patch.iconUrl || undefined } : {}), - ...(patch.color !== undefined && colorIsValid(patch.color) - ? { color: patch.color } - : {}), }; const nextMeta = { ...metaMap }; delete nextMeta[url]; - const isEmpty = - !mergedMeta.name && !mergedMeta.iconUrl && !mergedMeta.color; + const isEmpty = !mergedMeta.name && !mergedMeta.iconUrl; if (!isEmpty) { nextMeta[nextUrl] = mergedMeta; } diff --git a/packages/shared/src/features/shortcuts/types.ts b/packages/shared/src/features/shortcuts/types.ts index a52344d939a..b62067dc034 100644 --- a/packages/shared/src/features/shortcuts/types.ts +++ b/packages/shared/src/features/shortcuts/types.ts @@ -1,31 +1,12 @@ -export type ShortcutColor = - | 'burger' - | 'cheese' - | 'avocado' - | 'bacon' - | 'blueCheese' - | 'cabbage'; - -export const shortcutColorPalette: readonly ShortcutColor[] = [ - 'burger', - 'cheese', - 'avocado', - 'bacon', - 'blueCheese', - 'cabbage', -] as const; - export type ShortcutMeta = { name?: string; iconUrl?: string; - color?: ShortcutColor; }; export type Shortcut = { url: string; name?: string; iconUrl?: string; - color?: ShortcutColor; }; export type ImportSource = 'topSites' | 'bookmarks'; From fb2425a3b966aa05d98ab1834e3b8929a47d8731 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Apr 2026 16:31:38 +0200 Subject: [PATCH 31/32] fix: cleanup part 5 --- .../ShortcutLinks/ShortcutImportFlow.tsx | 67 +++++++------------ .../components/modals/ImportPickerModal.tsx | 9 +-- .../ShortcutsManageConnectionsSection.tsx | 38 ++--------- .../modals/ShortcutsManageModal.tsx | 9 +-- .../shortcuts/hooks/useShortcutsManager.ts | 9 +-- .../shortcuts/lib/getShortcutDedupKey.spec.ts | 46 +++++++++++++ .../shortcuts/lib/getShortcutDedupKey.ts | 20 ++++++ packages/shared/src/lib/links.spec.ts | 47 +------------ packages/shared/src/lib/links.ts | 27 -------- 9 files changed, 108 insertions(+), 164 deletions(-) create mode 100644 packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.spec.ts create mode 100644 packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.ts diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx index 00682f248d0..6e83c2c7e71 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect, useRef } from 'react'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; -import type { ImportPickerItem } from '@dailydotdev/shared/src/features/shortcuts/components/modals/ImportPickerModal'; 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'; @@ -23,13 +22,6 @@ import { MAX_SHORTCUTS, } from '@dailydotdev/shared/src/features/shortcuts/types'; -interface ImportFlowState { - hasCheckedPermission: boolean; - items?: ImportPickerItem[]; - askPermission: () => Promise; - emptyToast: string; -} - interface PermissionModalProps { onGrant: () => Promise; onClose: () => void; @@ -105,27 +97,22 @@ export function ShortcutImportFlow(): ReactElement | null { const handledRef = useRef(null); const closeImportFlow = () => setShowImportSource?.(null); - - const getImportState = (source: ImportSource): ImportFlowState => { - if (source === 'topSites') { - return { - hasCheckedPermission: hasCheckedTopSitesPermission, - items: topSites?.map((site) => ({ url: site.url })), - askPermission: askTopSitesPermission, - emptyToast: 'No top sites yet. Visit some sites and try again.', - }; - } - - return { - hasCheckedPermission: hasCheckedBookmarksPermission, - items: bookmarks?.map((bookmark) => ({ + 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, - })), - askPermission: askBookmarksPermission, - emptyToast: 'Your bookmarks bar is empty. Add some bookmarks and try again.', - }; - }; + }))) 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) { @@ -133,8 +120,7 @@ export function ShortcutImportFlow(): ReactElement | null { return; } - const importState = getImportState(showImportSource); - if (!importState.hasCheckedPermission || importState.items === undefined) { + if (!hasCheckedPermission || items === undefined) { return; } @@ -144,8 +130,8 @@ export function ShortcutImportFlow(): ReactElement | null { handledRef.current = showImportSource; const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); - if (importState.items.length === 0) { - displayToast(importState.emptyToast); + if (items.length === 0) { + displayToast(emptyToast); closeImportFlow(); return; } @@ -162,43 +148,38 @@ export function ShortcutImportFlow(): ReactElement | null { type: LazyModal.ImportPicker, props: { source: showImportSource, - items: importState.items, + items, returnTo: returnToAfterImport, }, }); closeImportFlow(); }, [ - askBookmarksPermission, - askTopSitesPermission, - bookmarks, customLinks, displayToast, - hasCheckedBookmarksPermission, - hasCheckedTopSitesPermission, + emptyToast, + hasCheckedPermission, + items, openModal, returnToAfterImport, - setShowImportSource, showImportSource, - topSites, ]); if (!showImportSource) { return null; } - const importState = getImportState(showImportSource); - if (!importState.hasCheckedPermission || importState.items !== undefined) { + if (!hasCheckedPermission || items !== undefined) { return null; } const handleGrant = async () => { - const granted = await importState.askPermission(); + const granted = await askPermission(); if (!granted) { closeImportFlow(); } }; - if (showImportSource === 'topSites') { + if (isTopSitesImport) { return ( (); return items.filter((item) => { - const canonicalUrl = canonicalShortcutUrl(item.url) ?? item.url; - if (seen.has(canonicalUrl)) { + const dedupKey = getShortcutDedupKey(item.url) ?? item.url; + if (seen.has(dedupKey)) { return false; } - seen.add(canonicalUrl); + seen.add(dedupKey); return true; }); }, [items]); diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx index e0fee981ec2..03d76359e1b 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx @@ -118,36 +118,14 @@ export function ShortcutsModeSection({ ); } -function getConnectionPrimaryAction({ - isConnected, - onConnectedAction, - onConnectAction, -}: { - isConnected: boolean; - onConnectedAction?: () => void; - onConnectAction?: () => void | Promise; -}): (() => void) | undefined { - if (isConnected) { - return onConnectedAction; - } - - if (!onConnectAction) { - return undefined; - } - - return () => { - onConnectAction(); - }; -} - interface ConnectionRowProps { icon: ReactElement; label: string; description: string; primaryLabel?: string; - onPrimary?: () => void; + onPrimary?: () => void | Promise; secondaryLabel?: string; - onSecondary?: () => void; + onSecondary?: () => void | Promise; trailing?: ReactElement; } @@ -240,11 +218,7 @@ export function AutoConnectionsSection({ : 'Grant access so we can read your most visited sites.' } primaryLabel={topSitesGranted ? 'Import' : 'Connect'} - onPrimary={getConnectionPrimaryAction({ - isConnected: topSitesGranted, - onConnectedAction: onImportTopSites, - onConnectAction: onAskTopSites, - })} + onPrimary={topSitesGranted ? onImportTopSites : onAskTopSites} secondaryLabel={topSitesGranted ? 'Disconnect' : undefined} onSecondary={topSitesGranted ? () => onRevokeTopSites?.() : undefined} /> @@ -299,11 +273,7 @@ export function BrowserConnectionsSection({ : 'Grant access to import your browser bookmarks.' } primaryLabel={bookmarksGranted ? 'Import' : 'Connect'} - onPrimary={getConnectionPrimaryAction({ - isConnected: bookmarksGranted, - onConnectedAction: onImportBookmarks, - onConnectAction: onAskBookmarks, - })} + onPrimary={bookmarksGranted ? onImportBookmarks : onAskBookmarks} secondaryLabel={bookmarksGranted ? 'Disconnect' : undefined} onSecondary={ bookmarksGranted ? () => onRevokeBookmarks?.() : undefined diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index af6ce32ac87..e69086e3b09 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -80,9 +80,6 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { props?.onRequestClose?.(undefined as never); }; - const closeEditor = () => setEditing(null); - const openEditor = (next: ShortcutsManageEditingState) => setEditing(next); - const mode = flags?.shortcutsMode ?? 'manual'; const appearance: ShortcutsAppearance = flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; @@ -136,8 +133,8 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { }; const handleEditShortcut = (shortcut: Shortcut) => - openEditor({ mode: 'edit', shortcut }); - const handleAddShortcut = () => openEditor({ mode: 'add' }); + setEditing({ mode: 'edit', shortcut }); + const handleAddShortcut = () => setEditing({ mode: 'add' }); const openTopSitesImport = setShowImportSource ? () => setShowImportSource('topSites', LazyModal.ShortcutsManage) @@ -151,7 +148,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { return ( setEditing(null)} {...props} /> ); diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts index f213c3a499e..aa199fbf75e 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts @@ -3,11 +3,12 @@ import { useSettingsContext } from '../../../contexts/SettingsContext'; import { useLogContext } from '../../../contexts/LogContext'; import { useToastNotification } from '../../../hooks/useToastNotification'; import { LogEvent, ShortcutsSourceType, TargetType } from '../../../lib/log'; -import { canonicalShortcutUrl, withHttps } from '../../../lib/links'; +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[]; @@ -78,7 +79,7 @@ export const useShortcutsManager = (): UseShortcutsManager => { const canonicalMap = useMemo(() => { const map = new Map(); links.forEach((url) => { - const key = canonicalShortcutUrl(url); + const key = getShortcutDedupKey(url); if (key) { map.set(key, url); } @@ -88,7 +89,7 @@ export const useShortcutsManager = (): UseShortcutsManager => { const findDuplicate = useCallback( (url: string) => { - const key = canonicalShortcutUrl(url); + const key = getShortcutDedupKey(url); if (!key) { return null; } @@ -267,7 +268,7 @@ export const useShortcutsManager = (): UseShortcutsManager => { return; } const httpsUrl = withHttps(item.url); - const key = canonicalShortcutUrl(httpsUrl); + const key = getShortcutDedupKey(httpsUrl); if (!key || existingKeys.has(key)) { skipped += 1; return; 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/lib/links.spec.ts b/packages/shared/src/lib/links.spec.ts index 3743ca2301b..c9e3c0805de 100644 --- a/packages/shared/src/lib/links.spec.ts +++ b/packages/shared/src/lib/links.spec.ts @@ -1,4 +1,4 @@ -import { canonicalShortcutUrl, withHttps } from './links'; +import { withHttps } from './links'; describe('lib/links tests', () => { it('should return links as https links', () => { @@ -14,49 +14,4 @@ describe('lib/links tests', () => { expect(withHttps(input)).toEqual(expected); }); }); - - describe('canonicalShortcutUrl', () => { - it('lowercases the host and strips trailing slashes', () => { - expect(canonicalShortcutUrl('HTTPS://Example.COM/Foo/')).toEqual( - 'https://example.com/Foo', - ); - }); - - it('collapses www. so www.example.com and example.com dedup', () => { - expect(canonicalShortcutUrl('https://www.example.com/')).toEqual( - 'https://example.com', - ); - expect(canonicalShortcutUrl('https://example.com')).toEqual( - 'https://example.com', - ); - }); - - it('preserves non-www subdomains', () => { - expect(canonicalShortcutUrl('https://blog.example.com')).toEqual( - 'https://blog.example.com', - ); - }); - - it('preserves non-default ports', () => { - expect(canonicalShortcutUrl('https://www.example.com:8443/path')).toEqual( - 'https://example.com:8443/path', - ); - }); - - it('preserves search params and hash so distinct pages dedup separately', () => { - expect(canonicalShortcutUrl('https://example.com/search?q=foo')).toEqual( - 'https://example.com/search?q=foo', - ); - expect(canonicalShortcutUrl('https://example.com/app#/route')).toEqual( - 'https://example.com/app#/route', - ); - expect( - canonicalShortcutUrl('https://example.com/search?q=foo'), - ).not.toEqual(canonicalShortcutUrl('https://example.com/search?q=bar')); - }); - - it('returns null for invalid input', () => { - expect(canonicalShortcutUrl('not a url')).toBeNull(); - }); - }); }); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index 455227d20c0..6bd1f11b1c0 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -29,33 +29,6 @@ export const stripLinkParameters = (link: string): string => { return origin + pathname; }; -/** - * Canonical URL form used for duplicate detection across shortcuts. - * We keep protocol, hostname (lower-cased, leading `www.` stripped), port, - * pathname (trailing slash stripped), search, and hash — so different - * hashes or query strings on the same site are treated as different - * shortcuts. The `www.` collapse matters because users routinely paste - * both `example.com` and `www.example.com` for the same site (and - * browsers treat them as one in top-sites/history), so without it the - * dedup pass would happily keep both tiles. - */ -export const canonicalShortcutUrl = (link: string): string | null => { - try { - const url = new URL(withHttps(link)); - const hostname = url.hostname.toLowerCase().replace(/^www\./, ''); - const pathname = url.pathname.replace(/\/+$/, ''); - // `url.port` is already normalized (empty for default ports), so we - // rebuild origin from parts instead of using `url.origin` which would - // bake the `www.` back in. - const port = url.port ? `:${url.port}` : ''; - return `${url.protocol.toLowerCase()}//${hostname}${port}${pathname}${ - url.search - }${url.hash}`; - } catch (_) { - return null; - } -}; - export const getDomainFromUrl = (link: string): string => { try { return new URL(withHttps(link)).hostname.replace(/^www\./, ''); From d7d504f338db325bd338e15374971c9bab31d9d8 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Apr 2026 16:40:03 +0200 Subject: [PATCH 32/32] fix: cleanup part 6 --- .../ShortcutLinks/ShortcutImportFlow.tsx | 27 +++--- .../shortcuts/components/ShortcutEditForm.tsx | 92 +++++++++++-------- .../components/WebappShortcutsRow.tsx | 2 +- .../modals/ShortcutsManageModal.tsx | 5 +- 4 files changed, 69 insertions(+), 57 deletions(-) diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx index 6e83c2c7e71..b6cc11fbf25 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef } 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'; @@ -96,17 +96,22 @@ export function ShortcutImportFlow(): ReactElement | null { const { openModal } = useLazyModal(); const handledRef = useRef(null); - const closeImportFlow = () => setShowImportSource?.(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 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; @@ -159,6 +164,7 @@ export function ShortcutImportFlow(): ReactElement | null { emptyToast, hasCheckedPermission, items, + closeImportFlow, openModal, returnToAfterImport, showImportSource, @@ -189,9 +195,6 @@ export function ShortcutImportFlow(): ReactElement | null { } return ( - + ); } diff --git a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx index b43df0e6bd7..f1d16917d5f 100644 --- a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx +++ b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx @@ -199,6 +199,57 @@ export function ShortcutEditForm({ 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 = { @@ -299,46 +350,7 @@ export function ShortcutEditForm({ aria-live="polite" className="flex min-h-[1.125rem] flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-center text-text-tertiary typo-caption1" > - {isUploading ? ( - - - Uploading… - - ) : hasCustomIcon ? ( - - ) : customIconFailed ? ( - - Couldn't load that image. Showing favicon instead. - - ) : isDropTarget ? ( - - Drop to use this image - - ) : ( - <> - - {faviconSrc - ? 'Tap or drop to upload' - : 'Tap or drop an image to upload'} - - - · - - - - )} + {iconStatus}
      {showUrlInput && (
      diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx index 05e08b2f985..73bb64bf301 100644 --- a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -42,11 +42,11 @@ export function WebappShortcutsRow({ 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 shortcuts = manualRow.shortcuts; const loggedRef = useRef(false); useEffect(() => { diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx index e69086e3b09..5e705bf995f 100644 --- a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -188,10 +188,7 @@ export default function ShortcutsManageModal(props: ModalProps): ReactElement { /> {showTopSites && ( - + )} {showTopSites && mode === 'auto' && (