Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/shared/src/components/brief/BriefCopyMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
children,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => (
<div>
{options.map(({ label, action }) => (
<button key={label} type="button" onClick={action}>
{label}
</button>
))}
</div>
),
}));

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<Post> = {}) =>
render(
<BriefCopyMenu post={{ ...post, ...props }} origin={Origin.BriefPage} />,
);

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(),
);
});
});
112 changes: 112 additions & 0 deletions packages/shared/src/components/brief/BriefCopyMenu.tsx
Original file line number Diff line number Diff line change
@@ -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: <MenuIcon Icon={showCopyIcon ? CopyIcon : LinkIcon} />,
label: 'Copy link',
action: () => copyLink({ post }),
},
...(post.summary
? [
{
icon: <MenuIcon Icon={DocsIcon} />,
label: 'Copy summary',
action: () => {
logCopyText('summary');
copyText({
textToCopy: post.summary,
message: '✅ Copied summary to clipboard',
});
},
},
]
: []),
{
icon: <MenuIcon Icon={CopyIcon} />,
label: 'Copy title and link',
action: () => {
logCopyText('title_link');
copyText({
textToCopy: `${post.title ?? ''}\n${link ?? ''}`.trim(),
message: '✅ Copied to clipboard',
});
},
},
];

return (
<DropdownMenu>
<DropdownMenuTrigger tooltip={{ content: label }} asChild>
<Button
type="button"
className={classNames('justify-center', className)}
size={buttonSize}
variant={buttonVariant}
aria-label={label}
icon={showCopyIcon ? <CopyIcon /> : <LinkIcon />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuOptions options={options} />
</DropdownMenuContent>
</DropdownMenu>
);
};
45 changes: 45 additions & 0 deletions packages/shared/src/components/brief/BriefListItem.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <button type="button">Copy</button>,
}));

const post = {
id: 'brief-1',
slug: 'brief-1',
Expand All @@ -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(
<BriefListItem
post={post}
title={post.title}
origin={Origin.BriefPage}
targetId={TargetId.List}
showCopyActions={false}
/>,
);

expect(
screen.queryByRole('button', { name: 'Copy' }),
).not.toBeInTheDocument();
});

it('delegates regular clicks to the parent handler and tracks the click', () => {
Expand Down
25 changes: 24 additions & 1 deletion packages/shared/src/components/brief/BriefListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = ({
Expand All @@ -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 });
Expand Down Expand Up @@ -86,7 +97,12 @@ export const BriefListItem = ({
<div className="hidden items-center mobileXL:flex">
<BriefGradientIcon secondary={!isRead} size={IconSize.Size48} />
</div>
<div className="flex w-full flex-col gap-1">
<div
className={classNames(
'flex w-full flex-col gap-1',
showCopy && 'min-w-0',
)}
>
<div className="flex items-center gap-2">
<Typography
type={TypographyType.Title3}
Expand Down Expand Up @@ -150,6 +166,13 @@ export const BriefListItem = ({
onAuxClick={(event) => event.button === 1 && trackBriefClick()}
/>
</Link>
{/* Rendered after the full-bleed CardLink overlay, with an explicit
z-index, so the menu stays clickable instead of being swallowed by it. */}
{showCopy && (
<div className="relative z-1 flex shrink-0 items-center">
<BriefCopyMenu post={post} origin={origin} />
</div>
)}
</article>
);
};
Loading
Loading