Skip to content
82 changes: 75 additions & 7 deletions packages/shared/src/components/brief/BriefListItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BriefListItem } from './BriefListItem';
import type { Post } from '../../graphql/posts';
import { LogEvent, Origin, TargetId } from '../../lib/log';

const mockOnPostClick = jest.fn();
const mockLogEvent = jest.fn();
const mockCopyLink = jest.fn();
const mockOpenSharePost = jest.fn();

let mockWithShareControls = false;

jest.mock('../../hooks/useOnPostClick', () => ({
__esModule: true,
Expand All @@ -20,6 +25,17 @@ jest.mock('../../hooks/usePlusSubscription', () => ({
usePlusSubscription: () => ({ isPlus: true }),
}));

jest.mock('../../hooks/useSharePost', () => ({
useSharePost: () => ({
copyLink: mockCopyLink,
openSharePost: mockOpenSharePost,
}),
}));

jest.mock('../../features/snapshot/useSharePlacement', () => ({
useSharePlacement: () => mockWithShareControls,
}));

const post = {
id: 'brief-1',
slug: 'brief-1',
Expand All @@ -30,18 +46,21 @@ const post = {

const renderComponent = (onClick = jest.fn()) =>
render(
<BriefListItem
post={post}
title={post.title}
onClick={onClick}
origin={Origin.BriefPage}
targetId={TargetId.List}
/>,
<QueryClientProvider client={new QueryClient()}>
<BriefListItem
post={post}
title={post.title}
onClick={onClick}
origin={Origin.BriefPage}
targetId={TargetId.List}
/>
</QueryClientProvider>,
);

describe('BriefListItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWithShareControls = false;
});

it('delegates regular clicks to the parent handler and tracks the click', () => {
Expand Down Expand Up @@ -87,4 +106,53 @@ describe('BriefListItem', () => {
expect(mockOnPostClick).toHaveBeenCalledWith({ post });
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});

it('renders no share controls while the placement is off', () => {
renderComponent();

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

it('keeps the share controls inside the card', () => {
mockWithShareControls = true;
renderComponent();

const button = screen.getByRole('button', { name: 'Copy link' });
const article = button.closest('article');
const column = article?.querySelector('div.flex.flex-col');

// `w-full` on the text column pushes the control past the card border.
expect(column).not.toHaveClass('w-full');
expect(column).toHaveClass('min-w-0', 'flex-1');
expect(article).toContainElement(button);
});

it('copies the brief link without opening the brief', () => {
mockWithShareControls = true;
const onClick = jest.fn();
renderComponent(onClick);

fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));

expect(mockCopyLink).toHaveBeenCalledWith({ post });
expect(onClick).not.toHaveBeenCalled();
expect(mockOnPostClick).not.toHaveBeenCalled();
});

it('opens the share surface without opening the brief', () => {
mockWithShareControls = true;
const onClick = jest.fn();
renderComponent(onClick);

fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));

expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
expect(mockCopyLink).not.toHaveBeenCalled();
expect(onClick).not.toHaveBeenCalled();
});
});
48 changes: 45 additions & 3 deletions packages/shared/src/components/brief/BriefListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import type { PillProps } from '../Pill';
import { Pill } from '../Pill';
import { IconSize } from '../Icon';
import { BriefGradientIcon, LockIcon } from '../icons';
import { BriefGradientIcon, LinkIcon, LockIcon, ShareIcon } from '../icons';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { Tooltip } from '../tooltip/Tooltip';
import type { Origin, TargetId } from '../../lib/log';
import { LogEvent } from '../../lib/log';
import useOnPostClick from '../../hooks/useOnPostClick';
Expand All @@ -22,6 +24,10 @@ import { anchorDefaultRel } from '../../lib/strings';
import Link from '../utilities/Link';
import { useLogContext } from '../../contexts/LogContext';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';
import { useSharePost } from '../../hooks/useSharePost';
import { CopyStateIcon } from '../share/CopyStateIcon';
import { featureBriefingShareControls } from '../../lib/featureManagement';
import { useSharePlacement } from '../../features/snapshot/useSharePlacement';

export type BriefListItemProps = {
className?: string;
Expand Down Expand Up @@ -55,6 +61,10 @@ export const BriefListItem = ({
const { isPlus } = usePlusSubscription();
const { logEvent } = useLogContext();
const onPostClick = useOnPostClick({ origin });
const { copyLink, isCopying, openSharePost } = useSharePost(origin);
const withShareControls = useSharePlacement({
feature: featureBriefingShareControls,
});

const trackBriefClick = () => {
onPostClick({ post });
Expand Down Expand Up @@ -86,14 +96,22 @@ 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="flex items-center gap-2">
<div
className={classNames(
'flex flex-col gap-1',
// `w-full` would claim the whole card and push the controls past
// its border.
withShareControls ? 'min-w-0 flex-1' : 'w-full',
)}
>
<div className="flex min-w-0 items-center gap-2">
<Typography
type={TypographyType.Title3}
bold
color={
isRead ? TypographyColor.Quaternary : TypographyColor.Primary
}
truncate={withShareControls}
>
{title}
</Typography>
Expand Down Expand Up @@ -150,6 +168,30 @@ export const BriefListItem = ({
onAuxClick={(event) => event.button === 1 && trackBriefClick()}
/>
</Link>
{withShareControls && (
// After the CardLink and above it: the overlay covers the whole row,
// so anything rendered before it never receives the click.
<div className="relative z-1 flex shrink-0 items-center gap-1">
<Tooltip content={isCopying ? 'Copied!' : 'Copy link'}>
<Button
aria-label="Copy link"
icon={<CopyStateIcon copied={isCopying} icon={LinkIcon} />}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
onClick={() => copyLink({ post })}
/>
</Tooltip>
<Tooltip content="Share">
<Button
aria-label="Share briefing"
icon={<ShareIcon />}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
onClick={() => openSharePost({ post })}
/>
</Tooltip>
</div>
)}
</article>
);
};
30 changes: 27 additions & 3 deletions packages/shared/src/components/post/brief/BriefPostContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ReactElement } from 'react';
import React, { useMemo, useEffect, useState } from 'react';
import React, { useMemo, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import {
Expand Down Expand Up @@ -46,7 +46,10 @@ import {
ButtonVariant,
} from '../../buttons/Button';
import { LogEvent, TargetId } from '../../../lib/log';
import { featurePlusCtaCopy } from '../../../lib/featureManagement';
import {
featureBriefingShareControls,
featurePlusCtaCopy,
} from '../../../lib/featureManagement';
import { LottieAnimation } from '../../LottieAnimation';
import { briefFeatureList, PlusList } from '../../plus/PlusList';
import { HourDropdown } from '../../fields/HourDropdown';
Expand All @@ -65,6 +68,10 @@ import { getFirstName } from '../../../lib/user';
import Link from '../../utilities/Link';
import { ActionType } from '../../../graphql/actions';
import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert';
import { BriefShareBand } from '../../../features/briefing/components/BriefShareBand';
import { BriefBodyShareControls } from '../../../features/briefing/components/BriefBodyShareControls';
import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar';
import { useSharePlacement } from '../../../features/snapshot/useSharePlacement';
import type { BriefPostHeaderProps } from '../../../features/briefing/components/BriefPostHeader';
import { BriefPostHeader } from '../../../features/briefing/components/BriefPostHeader';
import type { NotificationChannel } from '../../../hooks/notifications/useNotificationSettings';
Expand Down Expand Up @@ -135,6 +142,12 @@ const BriefPostContentRaw = ({
unsubscribePersonalizedDigest,
} = usePersonalizedDigest();
const [digestTimeIndex, setDigestTimeIndex] = useState<number | undefined>(8);
const briefBodyRef = useRef<HTMLDivElement>(null);
// The post page's highlight bar, on the briefing's own flag: one switch
// turns every control on this surface on or off together.
const isSelectionShareEnabled = useSharePlacement({
feature: featureBriefingShareControls,
});

const briefDigest = getPersonalizedDigest(UserPersonalizedDigestType.Brief);

Expand Down Expand Up @@ -390,7 +403,18 @@ const BriefPostContentRaw = ({
</Typography>
</div>
</div>
<Markdown content={contentHtml} />
<div ref={briefBodyRef}>
<Markdown content={contentHtml} />
</div>
{isSelectionShareEnabled && (
<SelectionSnapshotBar containerRef={briefBodyRef} post={post} />
)}
<BriefBodyShareControls
bodyRef={briefBodyRef}
contentHtml={contentHtml}
post={post}
/>
<BriefShareBand origin={origin} post={post} />
{isNotPlus && (
<div className="flex w-full rounded-12 border border-white bg-transparent">
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen } from '@testing-library/react';
import { BriefPostHeaderActions } from './BriefPostHeaderActions';
import type { Post } from '../../../graphql/posts';
import { Origin } from '../../../lib/log';

const mockCopyLink = jest.fn();
const mockOpenSharePost = jest.fn();

let mockAtEveryWidth = false;

jest.mock('../../../hooks/useSharePost', () => ({
useSharePost: () => ({
copyLink: mockCopyLink,
openSharePost: mockOpenSharePost,
}),
}));

jest.mock('../../../features/snapshot/useSharePlacement', () => ({
useSharePlacement: () => mockAtEveryWidth,
}));

const post = { id: 'brief-1', slug: 'brief-1' } as Post;

const renderComponent = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<BriefPostHeaderActions
showShareButton
contextMenuId="post-widgets-context"
origin={Origin.BriefPage}
post={post}
/>
</QueryClientProvider>,
);

describe('BriefPostHeaderActions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAtEveryWidth = false;
});

it('keeps the cluster desktop-only while the placement is off', () => {
renderComponent();

expect(
screen.getByRole('button', { name: 'Copy link' }).closest('div'),
).toHaveClass('hidden', 'laptop:flex');
expect(
screen.queryByRole('button', { name: 'Share briefing' }),
).not.toBeInTheDocument();
});

it('shows copy link and share at every width when the placement is on', () => {
mockAtEveryWidth = true;
renderComponent();

const cluster = screen
.getByRole('button', { name: 'Copy link' })
.closest('div');

expect(cluster).not.toHaveClass('hidden');
expect(
screen.getByRole('button', { name: 'Share briefing' }),
).toBeInTheDocument();
});

it('draws every control in the cluster at the same weight', () => {
mockAtEveryWidth = true;
renderComponent();

const controls = [
screen.getByRole('button', { name: 'Copy link' }),
screen.getByRole('button', { name: 'Share briefing' }),
screen.getByRole('link'),
];

controls.forEach((control) => expect(control).toHaveClass('btn-tertiary'));
});

it('copies the brief link and opens the share modal', () => {
mockAtEveryWidth = true;
renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));

expect(mockCopyLink).toHaveBeenCalledWith({ post });
expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
});
});
Loading
Loading