diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx index c4e7e582b15..34082474069 100644 --- a/packages/shared/src/components/brief/BriefListItem.spec.tsx +++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx @@ -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, @@ -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', @@ -30,18 +46,21 @@ const post = { const renderComponent = (onClick = jest.fn()) => render( - , + + + , ); describe('BriefListItem', () => { beforeEach(() => { jest.clearAllMocks(); + mockWithShareControls = false; }); it('delegates regular clicks to the parent handler and tracks the click', () => { @@ -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(); + }); }); diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx index 6782d7e6922..01fce4ea676 100644 --- a/packages/shared/src/components/brief/BriefListItem.tsx +++ b/packages/shared/src/components/brief/BriefListItem.tsx @@ -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'; @@ -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; @@ -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 }); @@ -86,14 +96,22 @@ export const BriefListItem = ({
-
-
+
+
{title} @@ -150,6 +168,30 @@ export const BriefListItem = ({ onAuxClick={(event) => event.button === 1 && trackBriefClick()} /> + {withShareControls && ( + // After the CardLink and above it: the overlay covers the whole row, + // so anything rendered before it never receives the click. +
+ +
+ )} ); }; diff --git a/packages/shared/src/components/post/brief/BriefPostContent.tsx b/packages/shared/src/components/post/brief/BriefPostContent.tsx index ad37fd2cbff..ac92e71bfa6 100644 --- a/packages/shared/src/components/post/brief/BriefPostContent.tsx +++ b/packages/shared/src/components/post/brief/BriefPostContent.tsx @@ -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 { @@ -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'; @@ -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'; @@ -135,6 +142,12 @@ const BriefPostContentRaw = ({ unsubscribePersonalizedDigest, } = usePersonalizedDigest(); const [digestTimeIndex, setDigestTimeIndex] = useState(8); + const briefBodyRef = useRef(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); @@ -390,7 +403,18 @@ const BriefPostContentRaw = ({
- +
+ +
+ {isSelectionShareEnabled && ( + + )} + + {isNotPlus && (
({ + 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( + + + , + ); + +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 }); + }); +}); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d1cb33406be..9f2897ca05d 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -4,11 +4,15 @@ 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 { LinkIcon, SettingsIcon } from '../../icons'; +import { LinkIcon, SettingsIcon, ShareIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; import { useSharePost } from '../../../hooks/useSharePost'; +import { CopyStateIcon } from '../../share/CopyStateIcon'; import type { Origin } from '../../../lib/log'; +import { featureBriefingShareControls } from '../../../lib/featureManagement'; +import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; const Container = classed('div', 'flex flex-row items-center'); @@ -26,20 +30,51 @@ export const BriefPostHeaderActions = ({ origin: Origin; showShareButton?: boolean; }): ReactElement => { - const { copyLink } = useSharePost(origin); + const { copyLink, isCopying, openSharePost } = useSharePost(origin); + const atEveryWidth = useSharePlacement({ + feature: featureBriefingShareControls, + shouldEvaluate: showShareButton, + }); return ( -
+
{showShareButton && ( + <> + +
diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts new file mode 100644 index 00000000000..fd62acebf30 --- /dev/null +++ b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts @@ -0,0 +1,98 @@ +import { + getBriefBlocks, + getBriefSection, + splitBriefBullet, +} from './briefBodyBlocks'; + +const BODY = ` +

Must know

+
    +
  • AI agents are taking over your dev tools: The shift is accelerating.
  • +
  • Postgres keeps eating the specialists: One engine, every workload.
  • +
+

Worth a look

+

A paragraph under the second heading.

+
    +
  • A bullet under the second heading.
  • +
+`; + +const render = (html = BODY) => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + + return container; +}; + +describe('getBriefBlocks', () => { + it('returns every bullet and paragraph in the body', () => { + const blocks = getBriefBlocks(render()); + + expect(blocks).toHaveLength(4); + expect(blocks[0].text).toContain( + 'AI agents are taking over your dev tools', + ); + expect(blocks[2].text).toBe('A paragraph under the second heading.'); + }); + + it('skips a paragraph that only wraps a list item', () => { + const blocks = getBriefBlocks( + render('
  • Wrapped bullet

'), + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].node.tagName).toBe('LI'); + }); + + it('drops empty blocks', () => { + expect(getBriefBlocks(render('

Real

'))).toHaveLength( + 1, + ); + }); +}); + +describe('getBriefSection', () => { + it('collects only the bullets under the named heading', () => { + const section = getBriefSection(render(), 'Must know'); + + expect(section?.heading.tagName).toBe('H2'); + expect(section?.blocks).toHaveLength(2); + expect(section?.blocks[1].text).toContain('Postgres keeps eating'); + }); + + it('stops at the next heading', () => { + const section = getBriefSection(render(), 'Worth a look'); + + expect(section?.blocks.map((block) => block.text)).toEqual([ + 'A paragraph under the second heading.', + 'A bullet under the second heading.', + ]); + }); + + it('matches the heading regardless of case', () => { + expect(getBriefSection(render(), 'must KNOW')?.blocks).toHaveLength(2); + }); + + it('returns null when the brief has no such section', () => { + expect(getBriefSection(render(), 'Deep dive')).toBeNull(); + }); +}); + +describe('splitBriefBullet', () => { + it('keeps the claim and drops the evidence', () => { + expect(splitBriefBullet('The claim: the evidence')).toBe('The claim'); + }); + + it('keeps a bullet with no lead whole', () => { + expect(splitBriefBullet('One sentence with no colon')).toBe( + 'One sentence with no colon', + ); + }); + + it('keeps a bullet whole when the colon is far too late to be a lead', () => { + const value = `${'a'.repeat(130)}: trailing`; + + expect(splitBriefBullet(value)).toBe(value); + }); +}); diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.ts b/packages/shared/src/features/briefing/briefBodyBlocks.ts new file mode 100644 index 00000000000..1abcba36f02 --- /dev/null +++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts @@ -0,0 +1,87 @@ +/** + * BriefPostContent renders the body through `` + * — one blob, no per-item nodes — so a control per bullet has nothing to hang + * off in JSX. These read the blocks back out of the rendered DOM, which is also + * the most faithful source: what the reader is actually looking at. + */ + +export interface BriefBlock { + node: HTMLElement; + text: string; +} + +export interface BriefSection { + heading: HTMLElement; + blocks: BriefBlock[]; +} + +const BLOCK_SELECTOR = 'li, p'; +const HEADING_SELECTOR = 'h1, h2, h3'; + +/* textContent, not innerText: innerText needs layout, which jsdom has none of, + and the collapsed whitespace is what a paste wants anyway. */ +const text = (node: HTMLElement) => + (node.textContent ?? '').replace(/\s+/g, ' ').trim(); + +/** Skips paragraphs that only wrap a list item, which would copy twice. */ +export function getBriefBlocks(container: HTMLElement): BriefBlock[] { + return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) + .filter((node) => !(node.tagName === 'P' && node.closest('li'))) + .map((node) => ({ node, text: text(node) })) + .filter((block) => block.text.length > 0); +} + +/** + * The section a heading opens, up to the next heading of any level. Matching is + * on the heading's own text because the backend sends no ids or classes. + */ +export function getBriefSection( + container: HTMLElement, + headingText: string, +): BriefSection | null { + const heading = Array.from( + container.querySelectorAll(HEADING_SELECTOR), + ).find( + (node) => text(node).toLowerCase() === headingText.toLowerCase().trim(), + ); + + if (!heading) { + return null; + } + + const blocks: BriefBlock[] = []; + let sibling = heading.nextElementSibling; + + while (sibling && !sibling.matches(HEADING_SELECTOR)) { + if (sibling instanceof HTMLElement) { + const nested = sibling.querySelectorAll('li'); + const nodes = nested.length ? Array.from(nested) : [sibling]; + + nodes.forEach((node) => { + const value = text(node); + + if (value) { + blocks.push({ node, text: value }); + } + }); + } + + sibling = sibling.nextElementSibling; + } + + return { heading, blocks }; +} + +/** + * Bullets read `the claim: the evidence`. Only the claim fits + * a card line, so the evidence is dropped. + */ +export function splitBriefBullet(value: string): string { + const separator = value.indexOf(':'); + + if (separator < 1 || separator > 120) { + return value; + } + + return value.slice(0, separator).trim(); +} diff --git a/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx b/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx new file mode 100644 index 00000000000..86c06361d29 --- /dev/null +++ b/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; +import { CopyStateIcon } from '../../../components/share/CopyStateIcon'; +import { useCopyText } from '../../../hooks/useCopy'; +import { truncateAtWordBoundary } from '../../../lib/strings'; +import { + ToastType, + useToastNotification, +} from '../../../hooks/useToastNotification'; + +/** Enough of the block to tell two buttons apart, not the whole paragraph. */ +const LABEL_LENGTH = 60; + +/** Copies the block and the brief link, so a paste carries both. */ +export function BriefBlockCopyButton({ + text, + link, +}: { + text: string; + link: string; +}): ReactElement { + const [copied, copy] = useCopyText([text, link].join('\n\n')); + const { displayToast } = useToastNotification(); + // Every bullet carries one of these, so a label that only said "Copy" would + // read as a wall of identical buttons on a screen reader. + const label = `Copy: ${truncateAtWordBoundary(text, LABEL_LENGTH)}`; + + // The clipboard rejects outright when the document is not focused, and a + // press that reports nothing at all reads as a dead button. + const onCopy = useCallback(async () => { + try { + await copy({ message: '✅ Copied' }); + } catch { + displayToast('❌ Your browser blocked the clipboard', { + variant: ToastType.Error, + }); + } + }, [copy, displayToast]); + + return ( + +