From f9440becf98a699474cd719668e5c3dc685a8e12 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 21 Apr 2026 14:13:52 +0300 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 && (