diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx new file mode 100644 index 0000000000..dc3c3e430c --- /dev/null +++ b/packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx @@ -0,0 +1,70 @@ +import type { ComponentProps } from 'react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import CustomFeedOptionsMenu from './CustomFeedOptionsMenu'; +import AuthContext from '../contexts/AuthContext'; +import type { AuthContextData } from '../contexts/AuthContext'; + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useFeeds: () => ({ feeds: { edges: [] } }), +})); + +const shareProps = { + text: "Check out Ido Shamun's profile on daily.dev", + link: 'https://app.daily.dev/idoshamun', +}; + +const renderMenu = ( + props: Partial> = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + return render( + + + + + , + ); +}; + +describe('CustomFeedOptionsMenu', () => { + it('should list the share option by default', async () => { + renderMenu({ shareProps }); + + // Radix opens the menu on keydown; jsdom lacks the pointer-event support + // its click path relies on. + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }); + + expect(await screen.findByText('Share')).toBeInTheDocument(); + expect(screen.getByText('Add to custom feed')).toBeInTheDocument(); + }); + + it('should drop the share option when the surface promotes it elsewhere', async () => { + renderMenu({ shareProps, hideShare: true }); + + // Radix opens the menu on keydown; jsdom lacks the pointer-event support + // its click path relies on. + fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' }); + + expect(await screen.findByText('Add to custom feed')).toBeInTheDocument(); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/CustomFeedOptionsMenu.tsx b/packages/shared/src/components/CustomFeedOptionsMenu.tsx index f4bc4b9bd6..f9ddbf7848 100644 --- a/packages/shared/src/components/CustomFeedOptionsMenu.tsx +++ b/packages/shared/src/components/CustomFeedOptionsMenu.tsx @@ -28,6 +28,8 @@ type CustomFeedOptionsMenuProps = { buttonVariant?: ButtonVariant; shareProps: UseShareOrCopyLinkProps; additionalOptions?: MenuItemProps[]; + /** Drop the in-menu share entry when the surface renders a visible one. */ + hideShare?: boolean; }; const CustomFeedOptionsMenu = ({ @@ -38,13 +40,14 @@ const CustomFeedOptionsMenu = ({ onCreateNewFeed, additionalOptions = [], buttonVariant = ButtonVariant.Float, + hideShare = false, }: CustomFeedOptionsMenuProps): ReactElement => { const { openModal } = useLazyModal(); const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); const { feeds } = useFeeds(); const handleOpenModal = () => { - if (feeds?.edges?.length > 0) { + if ((feeds?.edges?.length ?? 0) > 0) { return openModal({ type: LazyModal.AddToCustomFeed, props: { @@ -59,11 +62,15 @@ const CustomFeedOptionsMenu = ({ }; const options: MenuItemProps[] = [ - { - icon: , - label: 'Share', - action: () => onShareOrCopyLink(), - }, + ...(hideShare + ? [] + : [ + { + icon: , + label: 'Share', + action: () => onShareOrCopyLink(), + }, + ]), { icon: , label: 'Add to custom feed', diff --git a/packages/shared/src/components/profile/Header.tsx b/packages/shared/src/components/profile/Header.tsx index bfc29ef27a..e0c469e561 100644 --- a/packages/shared/src/components/profile/Header.tsx +++ b/packages/shared/src/components/profile/Header.tsx @@ -45,6 +45,7 @@ import { import Link from '../utilities/Link'; import type { MenuItemProps } from '../dropdown/common'; import { ProfileMobileBackButton } from './ProfileBackButton'; +import { ProfileShareButton } from './ProfileShareButton'; export interface HeaderProps { user: PublicProfile; @@ -218,6 +219,20 @@ export function Header({ variant={ButtonVariant.Float} /> )} + {/* Only while pinned: unpinned, the profile card right below owns the + share control, and two identical copy buttons on one screen read as + a mistake. `ml-1` keeps this utility icon out of the Follow group. */} + {sticky && ( + + )} {!isSameUser && ( @@ -241,6 +256,8 @@ export function Header({ `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, ) } + // Promoted out of the menu into the dedicated control above. + hideShare shareProps={{ text: `Check out ${user.name}'s profile on daily.dev`, link: user.permalink, diff --git a/packages/shared/src/components/profile/ProfileActions.tsx b/packages/shared/src/components/profile/ProfileActions.tsx index d81d537948..7236641814 100644 --- a/packages/shared/src/components/profile/ProfileActions.tsx +++ b/packages/shared/src/components/profile/ProfileActions.tsx @@ -67,7 +67,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { props: { offendingUser: { id: user.id, - username: user.username, + username: user.username || '', }, defaultBlockUser: defaultBlocked, }, @@ -87,12 +87,12 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { ? unblock({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', }) : block({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', }), }, { @@ -178,7 +178,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { follow({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', feedId, }) } @@ -186,7 +186,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { unfollow({ id: user.id, entity: ContentPreferenceType.User, - entityName: user.username, + entityName: user.username || '', feedId, }) } @@ -195,6 +195,8 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => { `/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`, ) } + // Promoted out of the menu into the header's share control. + hideShare shareProps={{ text: `Check out ${user.name}'s profile on daily.dev`, link: user.permalink, diff --git a/packages/shared/src/components/profile/ProfileHeader.spec.tsx b/packages/shared/src/components/profile/ProfileHeader.spec.tsx new file mode 100644 index 0000000000..7f4d1b17fc --- /dev/null +++ b/packages/shared/src/components/profile/ProfileHeader.spec.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ProfileHeader from './ProfileHeader'; +import AuthContext from '../../contexts/AuthContext'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import { getLogContextStatic } from '../../contexts/LogContext'; +import type { PublicProfile } from '../../lib/user'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +jest.mock('./ProfileActions', () => ({ + __esModule: true, + default: () =>
, +})); + +const user = { + id: 'u1', + name: 'Ido Shamun', + username: 'idoshamun', + permalink: 'https://app.daily.dev/idoshamun', + reputation: 10, + createdAt: '2020-01-01T00:00:00.000Z', + bio: 'Building daily.dev', + image: 'https://daily.dev/image.jpg', + cover: 'https://daily.dev/cover.jpg', +} as PublicProfile; + +const userStats = { upvotes: 1, numFollowers: 2, numFollowing: 3 }; + +const logEvent = jest.fn(); + +const renderHeader = (isSameUser: boolean) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const LogContext = getLogContextStatic(); + + return render( + + + false, + }} + > + + + + , + ); +}; + +describe('ProfileHeader share control', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should fill the edit slot with the share control on a public profile', () => { + renderHeader(false); + + expect( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ).toBeInTheDocument(); + // The invisible edit placeholder that used to hold the row height is gone. + expect(screen.queryByLabelText('Edit profile')).not.toBeInTheDocument(); + }); + + it('should sit next to the edit button on the owner profile', () => { + renderHeader(true); + + expect( + screen.getByLabelText('Copy link to your profile'), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Edit profile')).toBeInTheDocument(); + }); + + it('should log share profile from the header control', async () => { + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + + renderHeader(false); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.Profile, + }), + }), + ); + }); + + it('should render both controls at the same size and variant', () => { + renderHeader(true); + + [ + screen.getByLabelText('Copy link to your profile'), + screen.getByLabelText('Edit profile'), + ].forEach((control) => { + expect(control).toHaveClass('btn-subtle'); + expect(control).toHaveClass('h-8'); + }); + }); +}); diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 24a3ae432e..a649caa877 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -1,6 +1,5 @@ import React from 'react'; import dynamic from 'next/dynamic'; -import classNames from 'classnames'; import { Image } from '../image/Image'; import { Typography, @@ -13,7 +12,7 @@ import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -23,6 +22,7 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { ProfileShareButton } from './ProfileShareButton'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -75,19 +75,27 @@ const ProfileHeader = ({ className="absolute left-6 top-16 h-[7.5rem] w-[7.5rem] rounded-16 object-cover" />
- -
{name} diff --git a/packages/shared/src/components/profile/ProfileShareButton.spec.tsx b/packages/shared/src/components/profile/ProfileShareButton.spec.tsx new file mode 100644 index 0000000000..1d4af1fbb0 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.spec.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ProfileShareButton } from './ProfileShareButton'; +import { getLogContextStatic } from '../../contexts/LogContext'; +import AuthContext from '../../contexts/AuthContext'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import type { PublicProfile } from '../../lib/user'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { shouldUseNativeShare } from '../../lib/func'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +jest.mock('../../lib/func', () => ({ + ...jest.requireActual('../../lib/func'), + shouldUseNativeShare: jest.fn(), +})); + +const mockShouldUseNativeShare = jest.mocked(shouldUseNativeShare); + +const user = { + id: 'u1', + name: 'Ido Shamun', + username: 'idoshamun', + permalink: 'https://app.daily.dev/idoshamun', +} as PublicProfile; + +const logEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +const setupButton = ( + props: Partial> = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const LogContext = getLogContextStatic(); + + render( + + + false, + }} + > + + + + , + ); + + return client; +}; + +describe('ProfileShareButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShouldUseNativeShare.mockReturnValue(false); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + }); + + it('should label the control for the profile being copied', () => { + setupButton(); + + expect( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ).toBeInTheDocument(); + }); + + it('should label the control for the logged-in owner', () => { + setupButton({ isSameUser: true }); + + expect( + screen.getByLabelText('Copy link to your profile'), + ).toBeInTheDocument(); + }); + + it('should copy the profile link and name it in the toast', async () => { + const client = setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith('https://app.daily.dev/idoshamun'), + ); + await waitFor(() => { + const toast = client.getQueryData(TOAST_NOTIF_KEY); + expect(toast?.message).toEqual("✅ Copied link to @idoshamun's profile"); + }); + // Exact payload: `share profile` already ships from the profile menus and + // the owner share widget, and the post copy-link path uses the same + // target_id / target_type / stringified {provider, origin} shape. Pin it so + // the dashboards keep matching. + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.Profile, + }), + }); + }); + + it('should say "your profile" in the owner toast', async () => { + const client = setupButton({ isSameUser: true }); + + await userEvent.click(screen.getByLabelText('Copy link to your profile')); + + await waitFor(() => { + const toast = client.getQueryData(TOAST_NOTIF_KEY); + expect(toast?.message).toEqual('✅ Copied link to your profile'); + }); + }); + + it('should flip the glyph to a green check while copying', async () => { + setupButton(); + + const button = screen.getByLabelText("Copy link to @idoshamun's profile"); + expect(button.querySelector('.text-status-success')).toBeNull(); + + await userEvent.click(button); + + await waitFor(() => + expect(button.querySelector('.text-status-success')).toBeInTheDocument(), + ); + }); + + it('should open the native share sheet on mobile when available', async () => { + mockShouldUseNativeShare.mockReturnValue(true); + const share = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis.navigator, 'share', { + configurable: true, + value: share, + }); + + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: "Check out Ido Shamun's profile on daily.dev\nhttps://app.daily.dev/idoshamun", + }), + ); + expect(writeText).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareProfile, + target_id: 'u1', + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ + provider: ShareProvider.Native, + origin: Origin.Profile, + }), + }); + }); + + it('should not log when the native share sheet is dismissed', async () => { + mockShouldUseNativeShare.mockReturnValue(true); + Object.defineProperty(globalThis.navigator, 'share', { + configurable: true, + value: jest.fn().mockRejectedValue(new Error('AbortError')), + }); + + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => expect(logEvent).not.toHaveBeenCalled()); + }); + + it('should not open a share popover on desktop', async () => { + setupButton(); + + await userEvent.click( + screen.getByLabelText("Copy link to @idoshamun's profile"), + ); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(screen.queryByText('LinkedIn')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/profile/ProfileShareButton.tsx b/packages/shared/src/components/profile/ProfileShareButton.tsx new file mode 100644 index 0000000000..5f7405a254 --- /dev/null +++ b/packages/shared/src/components/profile/ProfileShareButton.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { LinkIcon, VIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import type { PublicProfile } from '../../lib/user'; +import type { ShareProvider } from '../../lib/share'; + +export interface ProfileShareButtonProps { + user: PublicProfile; + isSameUser?: boolean; + buttonSize?: ButtonSize; + buttonVariant?: ButtonVariant; + className?: string; +} + +/** + * Copy-link control for a profile. One click copies the (shortened, referral- + * tagged) profile URL and confirms twice over — the glyph flips to a green + * check for a beat and a toast names what was copied. No share menu: picking a + * network is a second decision, and the link on the clipboard covers every + * destination. Mobile still gets the native share sheet where the platform + * offers one. + */ +export function ProfileShareButton({ + user, + isSameUser, + buttonSize = ButtonSize.Small, + buttonVariant = ButtonVariant.Subtle, + className, +}: ProfileShareButtonProps): ReactElement { + const text = isSameUser + ? 'Check out my profile on daily.dev' + : `Check out ${user.name}'s profile on daily.dev`; + const label = isSameUser + ? 'Copy link to your profile' + : `Copy link to @${user.username}'s profile`; + + const [copying, shareOrCopy] = useShareOrCopyLink({ + link: user.permalink, + text, + cid: ReferralCampaignKey.ShareProfile, + copyMessage: isSameUser + ? '✅ Copied link to your profile' + : `✅ Copied link to @${user.username}'s profile`, + logObject: (provider: ShareProvider) => ({ + event_name: LogEvent.ShareProfile, + target_id: user.id, + target_type: TargetType.ProfilePage, + extra: JSON.stringify({ provider, origin: Origin.Profile }), + }), + }); + + return ( + +
+); + +/** + * Renders another story in a narrow iframe so the genuinely viewport-driven + * branches (`useViewSize(ViewSize.Laptop)`) can be reviewed at mobile width on + * the same page — no faking, it is the real component at 390px. + */ +export const DeviceFrame = ({ + storyId, + width = 390, + height = 220, +}: { + storyId: string; + width?: number; + height?: number; +}): ReactElement => { + // The embedded story is a separate document, so Storybook's theme toggle + // doesn't reach it — mirror the root theme class into its globals instead. + const [theme, setTheme] = React.useState('light'); + + React.useEffect(() => { + const root = document.documentElement; + const sync = () => + setTheme(root.classList.contains('dark') ? 'dark' : 'light'); + sync(); + const observer = new MutationObserver(sync); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + return ( +
+