diff --git a/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx b/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx new file mode 100644 index 00000000000..7fc272e6ed1 --- /dev/null +++ b/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { BriefCopyMenu } from './BriefCopyMenu'; +import type { MenuItemProps } from '../dropdown/common'; +import type { Post } from '../../graphql/posts'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +const mockDisplayToast = jest.fn(); +const mockLogEvent = jest.fn(); +const mockCopyLink = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('../../hooks/useSharePost', () => ({ + useSharePost: () => ({ copyLink: mockCopyLink }), +})); + +jest.mock('../../hooks/useShareCopyIcon', () => ({ + useShareCopyIcon: () => true, +})); + +jest.mock('../dropdown/DropdownMenu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => + children, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => ( +
+ {options.map(({ label, action }) => ( + + ))} +
+ ), +})); + +const post = { + id: 'brief-1', + title: 'Presidential briefing', + summary: 'Rust 1.90 lands async closures.', + commentsPermalink: 'https://app.daily.dev/posts/brief-1', +} as Post; + +const renderComponent = (props: Partial = {}) => + render( + , + ); + +describe('BriefCopyMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); + }); + + it('exposes an accessible trigger and every copy action', () => { + renderComponent(); + + expect(screen.getByLabelText('Copy')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy link' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy summary' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy title and link' }), + ).toBeInTheDocument(); + }); + + it('hides the summary action when the brief has no generated summary', () => { + renderComponent({ summary: undefined }); + + expect( + screen.queryByRole('button', { name: 'Copy summary' }), + ).not.toBeInTheDocument(); + }); + + it('delegates copy link to the shared post-share path', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + }); + + it('writes the summary to the clipboard, toasts and logs the share', async () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy summary' })); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(post.summary)); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied summary to clipboard', + expect.anything(), + ); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + // Text copies are distinguishable from the link copy by provider and + // the content discriminator. + extra: expect.stringContaining(ShareProvider.CopyText), + }), + ); + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + extra: expect.stringContaining('"content":"summary"'), + }), + ); + }); + + it('writes the title and link snippet to the clipboard', async () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Copy title and link' }), + ); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `${post.title}\n${post.commentsPermalink}`, + ), + ); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied to clipboard', + expect.anything(), + ); + }); +}); diff --git a/packages/shared/src/components/brief/BriefCopyMenu.tsx b/packages/shared/src/components/brief/BriefCopyMenu.tsx new file mode 100644 index 00000000000..aa8a90095c0 --- /dev/null +++ b/packages/shared/src/components/brief/BriefCopyMenu.tsx @@ -0,0 +1,112 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon, DocsIcon, LinkIcon } from '../icons'; +import { MenuIcon } from '../MenuIcon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import type { MenuItemProps } from '../dropdown/common'; +import type { Post } from '../../graphql/posts'; +import { useCopyText } from '../../hooks/useCopy'; +import { useSharePost } from '../../hooks/useSharePost'; +import { useShareCopyIcon } from '../../hooks/useShareCopyIcon'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePostLogEvent } from '../../lib/feed'; +import { LogEvent } from '../../lib/log'; +import type { Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +export interface BriefCopyMenuProps { + post: Post; + origin: Origin; + buttonSize?: ButtonSize; + buttonVariant?: ButtonVariant; + className?: string; + label?: string; +} + +// Copy affordances for a single briefing / digest post: the link itself, the +// generated summary, and a ready-to-paste title + link snippet. Grouped in a +// menu so a dense list row only grows by one control. +export const BriefCopyMenu = ({ + post, + origin, + buttonSize = ButtonSize.Small, + buttonVariant = ButtonVariant.Tertiary, + className, + label = 'Copy', +}: BriefCopyMenuProps): ReactElement => { + const { copyLink } = useSharePost(origin); + const [, copyText] = useCopyText(); + const showCopyIcon = useShareCopyIcon(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + + // Text copies log CopyText + a content discriminator so the three menu + // actions stay distinguishable in analytics (copy link logs CopyLink via the + // shared post-share path). + const logCopyText = (content: 'summary' | 'title_link') => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider: ShareProvider.CopyText, origin, content }, + }), + ); + + const link = post.commentsPermalink; + const options: MenuItemProps[] = [ + { + icon: , + label: 'Copy link', + action: () => copyLink({ post }), + }, + ...(post.summary + ? [ + { + icon: , + label: 'Copy summary', + action: () => { + logCopyText('summary'); + copyText({ + textToCopy: post.summary, + message: '✅ Copied summary to clipboard', + }); + }, + }, + ] + : []), + { + icon: , + label: 'Copy title and link', + action: () => { + logCopyText('title_link'); + copyText({ + textToCopy: `${post.title ?? ''}\n${link ?? ''}`.trim(), + message: '✅ Copied to clipboard', + }); + }, + }, + ]; + + return ( + + + , +})); + const post = { id: 'brief-1', slug: 'brief-1', @@ -42,6 +52,41 @@ const renderComponent = (onClick = jest.fn()) => describe('BriefListItem', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseShareBriefingDigest.mockReturnValue(false); + }); + + it('keeps the row untouched while the share gate is off', () => { + renderComponent(); + + // The control row is a link and nothing else — no interactive controls. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByRole('link')).toBeInTheDocument(); + }); + + it('renders the copy menu once the share gate is on', () => { + mockUseShareBriefingDigest.mockReturnValue(true); + + renderComponent(); + + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); + + it('lets an explicit prop pin the copy menu off even when the gate is on', () => { + mockUseShareBriefingDigest.mockReturnValue(true); + + render( + , + ); + + expect( + screen.queryByRole('button', { name: 'Copy' }), + ).not.toBeInTheDocument(); }); it('delegates regular clicks to the parent handler and tracks the click', () => { diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx index 6782d7e6922..ac4f2c02874 100644 --- a/packages/shared/src/components/brief/BriefListItem.tsx +++ b/packages/shared/src/components/brief/BriefListItem.tsx @@ -22,6 +22,8 @@ import { anchorDefaultRel } from '../../lib/strings'; import Link from '../utilities/Link'; import { useLogContext } from '../../contexts/LogContext'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { useShareBriefingDigest } from '../../hooks/useShareBriefingDigest'; +import { BriefCopyMenu } from './BriefCopyMenu'; export type BriefListItemProps = { className?: string; @@ -36,6 +38,12 @@ export type BriefListItemProps = { origin: Origin; post: Post; targetId: TargetId; + /** + * Renders the per-item copy menu. Left undefined it resolves from the + * `share_briefing_digest` gate; pass it explicitly to pin a state in + * Storybook or tests, where GrowthBook is mocked. + */ + showCopyActions?: boolean; }; export const BriefListItem = ({ @@ -51,7 +59,10 @@ export const BriefListItem = ({ origin, post, targetId, + showCopyActions, }: BriefListItemProps): ReactElement => { + const isShareEnabled = useShareBriefingDigest(); + const showCopy = showCopyActions ?? isShareEnabled; const { isPlus } = usePlusSubscription(); const { logEvent } = useLogContext(); const onPostClick = useOnPostClick({ origin }); @@ -86,7 +97,12 @@ export const BriefListItem = ({
-
+
event.button === 1 && trackBriefClick()} /> + {/* Rendered after the full-bleed CardLink overlay, with an explicit + z-index, so the menu stays clickable instead of being swallowed by it. */} + {showCopy && ( +
+ +
+ )} ); }; diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx new file mode 100644 index 00000000000..0c1be190025 --- /dev/null +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { BriefPostHeaderActions } from './BriefPostHeaderActions'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Post } from '../../../graphql/posts'; +import { Origin } from '../../../lib/log'; +import { useViewSize } from '../../../hooks/useViewSize'; + +const mockUseShareBriefingDigest = jest.fn(); +const mockCopyLink = jest.fn(); +const share = jest.fn().mockResolvedValue(undefined); +const writeText = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../../hooks/useShareBriefingDigest', () => ({ + useShareBriefingDigest: () => mockUseShareBriefingDigest(), +})); + +jest.mock('../../../hooks/useSharePost', () => ({ + useSharePost: () => ({ copyLink: mockCopyLink }), +})); + +jest.mock('../../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; + +const post = { + id: 'brief-1', + title: 'Presidential briefing', + summary: 'Rust 1.90 lands async closures.', + commentsPermalink: 'https://app.daily.dev/posts/brief-1', +} as Post; + +// Mirrors how BriefPostContent renders the header (share always requested) and +// how DigestPostContent renders it (share requested only behind the gate). +const renderBrief = (): RenderResult => + render( + + + , + ); + +const renderDigest = (): RenderResult => + render( + + + , + ); + +describe('BriefPostHeaderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); + mockUseShareBriefingDigest.mockReturnValue(false); + Object.assign(navigator, { clipboard: { writeText } }); + }); + + describe('with the share gate off', () => { + it('keeps the legacy copy-link button on the brief', () => { + renderBrief(); + + fireEvent.click(screen.getByLabelText('Copy link')); + + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + expect(screen.queryByLabelText('Share briefing')).not.toBeInTheDocument(); + }); + + it('leaves the digest post with no share affordance at all', () => { + renderDigest(); + + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Share briefing')).not.toBeInTheDocument(); + }); + }); + + describe('with the share gate on', () => { + beforeEach(() => mockUseShareBriefingDigest.mockReturnValue(true)); + + it('gives the brief the share popover instead of the legacy button', () => { + renderBrief(); + + expect(screen.getByLabelText('Share briefing')).toBeInTheDocument(); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + + it('gives the digest post the same share affordance as the brief', () => { + renderDigest(); + + expect(screen.getByLabelText('Share briefing')).toBeInTheDocument(); + }); + + it('taps straight through to the native share sheet on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + Object.assign(navigator, { share, maxTouchPoints: 5 }); + + renderDigest(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share briefing')); + }); + + await waitFor(() => expect(share).toHaveBeenCalled()); + expect(writeText).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d31a26c0abb..792e64fbaaf 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -4,12 +4,18 @@ import classNames from 'classnames'; import classed from '../../../lib/classed'; import type { PostHeaderActionsProps } from '../common'; import Link from '../../utilities/Link'; -import { Button, ButtonSize } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { settingsUrl } from '../../../lib/constants'; import { CopyIcon, LinkIcon, SettingsIcon } from '../../icons'; import { useSharePost } from '../../../hooks/useSharePost'; import { useShareCopyIcon } from '../../../hooks/useShareCopyIcon'; +import { useShareBriefingDigest } from '../../../hooks/useShareBriefingDigest'; +import { ShareActions } from '../../share/ShareActions'; +import { useLogContext } from '../../../contexts/LogContext'; +import { usePostLogEvent } from '../../../lib/feed'; +import { LogEvent } from '../../../lib/log'; import type { Origin } from '../../../lib/log'; +import { ReferralCampaignKey } from '../../../lib/referral'; const Container = classed('div', 'flex flex-row items-center'); @@ -29,11 +35,36 @@ export const BriefPostHeaderActions = ({ }): ReactElement => { const { copyLink } = useSharePost(origin); const showCopyIcon = useShareCopyIcon(); + const isShareEnabled = useShareBriefingDigest(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); return ( + {/* Rendered outside the laptop-only wrapper: sharing a briefing is at + least as valuable on mobile, where ShareActions taps straight through + to the native share sheet. */} + {showShareButton && isShareEnabled && ( + + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin }, + }), + ) + } + /> + )}
- {showShareButton && ( + {showShareButton && !isShareEnabled && (