From 6918d3f94792e868fa40bd5a4f33db08314d3f86 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 16:55:59 +0300 Subject: [PATCH 1/3] feat(share): copy actions on briefings and digest share parity Co-Authored-By: Claude Opus 4.8 --- .../src/components/brief/BriefCopyMenu.tsx | 109 +++++++++++++ .../src/components/brief/BriefListItem.tsx | 25 ++- .../post/brief/BriefPostHeaderActions.tsx | 35 +++- .../post/digest/DigestPostContent.tsx | 5 + .../src/hooks/useShareBriefingDigest.ts | 16 ++ packages/shared/src/lib/featureManagement.ts | 10 ++ .../components/BriefSharing.stories.tsx | 154 ++++++++++++++++++ 7 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/components/brief/BriefCopyMenu.tsx create mode 100644 packages/shared/src/hooks/useShareBriefingDigest.ts create mode 100644 packages/storybook/stories/components/BriefSharing.stories.tsx diff --git a/packages/shared/src/components/brief/BriefCopyMenu.tsx b/packages/shared/src/components/brief/BriefCopyMenu.tsx new file mode 100644 index 00000000000..be4bc5a12e1 --- /dev/null +++ b/packages/shared/src/components/brief/BriefCopyMenu.tsx @@ -0,0 +1,109 @@ +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(); + + const logCopy = () => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider: ShareProvider.CopyLink, origin }, + }), + ); + + const link = post.commentsPermalink; + const options: MenuItemProps[] = [ + { + icon: , + label: 'Copy link', + action: () => copyLink({ post }), + }, + ...(post.summary + ? [ + { + icon: , + label: 'Copy summary', + action: () => { + logCopy(); + copyText({ + textToCopy: post.summary, + message: '✅ Copied summary to clipboard', + }); + }, + }, + ] + : []), + { + icon: , + label: 'Copy title and link', + action: () => { + logCopy(); + copyText({ + textToCopy: `${post.title ?? ''}\n${link ?? ''}`.trim(), + message: '✅ Copied to clipboard', + }); + }, + }, + ]; + + return ( + + + + ))} + + ), +})); + +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, + extra: expect.stringContaining(ShareProvider.CopyLink), + }), + ); + }); + + 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/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx index c4e7e582b15..5418c33ab85 100644 --- a/packages/shared/src/components/brief/BriefListItem.spec.tsx +++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx @@ -20,6 +20,16 @@ jest.mock('../../hooks/usePlusSubscription', () => ({ usePlusSubscription: () => ({ isPlus: true }), })); +const mockUseShareBriefingDigest = jest.fn(); + +jest.mock('../../hooks/useShareBriefingDigest', () => ({ + useShareBriefingDigest: () => mockUseShareBriefingDigest(), +})); + +jest.mock('./BriefCopyMenu', () => ({ + BriefCopyMenu: () => , +})); + 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/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/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx index 08a708298c5..54ba5bd96ef 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.tsx +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -12,6 +12,7 @@ import { useViewPost } from '../../../hooks/post/useViewPost'; import { withPostById } from '../withPostById'; import PostContentContainer from '../PostContentContainer'; import { BasePostContent } from '../BasePostContent'; +import type { Post } from '../../../graphql/posts'; import type { PostContentProps, PostNavigationProps } from '../common'; import { PostContainer } from '../common'; import { ToastSubject, useToastNotification } from '../../../hooks'; @@ -41,6 +42,10 @@ import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert'; import { transformDigestAd } from './utils'; +type DigestPostContentRawProps = Omit & { + post: Post; +}; + const DigestPostContentRaw = ({ post, className = {}, @@ -57,7 +62,7 @@ const DigestPostContentRaw = ({ backToSquad, isBannerVisible, isPostPage, -}: PostContentProps): ReactElement => { +}: DigestPostContentRawProps): ReactElement => { const { user, isLoggedIn } = useAuthContext(); const { isPlus } = usePlusSubscription(); const { subject } = useToastNotification(); @@ -72,6 +77,7 @@ const DigestPostContentRaw = ({ ); const postsCount = post?.flags?.posts || 0; const sourcesCount = post?.flags?.sources || 0; + const collectionSources = post?.collectionSources ?? []; const digestPostIds = post?.flags?.digestPostIds; const hasNavigation = !!onPreviousPost || !!onNextPost; @@ -176,7 +182,7 @@ const DigestPostContentRaw = ({ isBannerVisible, className: className?.fixedNavigation, } - : null + : undefined } >
- {post.collectionSources?.length > 0 && ( + {collectionSources.length > 0 && (
diff --git a/packages/storybook/stories/components/BriefSharing.stories.tsx b/packages/storybook/stories/components/BriefSharing.stories.tsx index 03e49e4a4e7..d4be4cb50ea 100644 --- a/packages/storybook/stories/components/BriefSharing.stories.tsx +++ b/packages/storybook/stories/components/BriefSharing.stories.tsx @@ -121,6 +121,9 @@ export const ListItemWithCopyActions: StoryObj = { }; // Flag-off control: the row renders exactly as it does on main today. +// `showCopyActions` is pinned explicitly because Storybook aliases GrowthBook to +// a mock that returns the string 'control' — truthy — for every flag, so the +// gate always reads as ON here. Flag-off behaviour is asserted in Jest instead. export const ListItemControl: StoryObj = { render: () => ( Date: Wed, 22 Jul 2026 22:07:36 +0300 Subject: [PATCH 3/3] fix(share): distinguish brief text copies from the link copy Copy summary and Copy title-and-link now log ShareProvider.CopyText with a content discriminator, so the three menu actions are no longer analytically identical. CopyText matches the sibling text-selection branch byte-for-byte. Co-Authored-By: Claude Opus 4.8 --- .../src/components/brief/BriefCopyMenu.spec.tsx | 9 ++++++++- .../shared/src/components/brief/BriefCopyMenu.tsx | 11 +++++++---- packages/shared/src/lib/share.ts | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx b/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx index d5c95f2b3ba..7fc272e6ed1 100644 --- a/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx +++ b/packages/shared/src/components/brief/BriefCopyMenu.spec.tsx @@ -109,7 +109,14 @@ describe('BriefCopyMenu', () => { expect(mockLogEvent).toHaveBeenCalledWith( expect.objectContaining({ event_name: LogEvent.SharePost, - extra: expect.stringContaining(ShareProvider.CopyLink), + // 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"'), }), ); }); diff --git a/packages/shared/src/components/brief/BriefCopyMenu.tsx b/packages/shared/src/components/brief/BriefCopyMenu.tsx index be4bc5a12e1..aa8a90095c0 100644 --- a/packages/shared/src/components/brief/BriefCopyMenu.tsx +++ b/packages/shared/src/components/brief/BriefCopyMenu.tsx @@ -47,10 +47,13 @@ export const BriefCopyMenu = ({ const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); - const logCopy = () => + // 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.CopyLink, origin }, + extra: { provider: ShareProvider.CopyText, origin, content }, }), ); @@ -67,7 +70,7 @@ export const BriefCopyMenu = ({ icon: , label: 'Copy summary', action: () => { - logCopy(); + logCopyText('summary'); copyText({ textToCopy: post.summary, message: '✅ Copied summary to clipboard', @@ -80,7 +83,7 @@ export const BriefCopyMenu = ({ icon: , label: 'Copy title and link', action: () => { - logCopy(); + logCopyText('title_link'); copyText({ textToCopy: `${post.title ?? ''}\n${link ?? ''}`.trim(), message: '✅ Copied to clipboard', diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts index b6bb78125b7..3651f7adeb2 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -10,6 +10,7 @@ export enum ShareProvider { LinkedIn = 'linkedin', Telegram = 'telegram', Email = 'email', + CopyText = 'copy text', } export const getWhatsappShareLink = (link: string): string =>