diff --git a/packages/shared/src/components/brief/BriefContent.spec.tsx b/packages/shared/src/components/brief/BriefContent.spec.tsx new file mode 100644 index 00000000000..3104cbbf4c4 --- /dev/null +++ b/packages/shared/src/components/brief/BriefContent.spec.tsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BriefContent } from './BriefContent'; +import type { Post } from '../../graphql/posts'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ToastType } from '../../hooks/useToastNotification'; + +// The copied link must be the tracked/shortened one, not `post.commentsPermalink` +// — asserting against this value is what proves referral attribution survives. +const shortLink = 'https://dly.to/abc123'; + +jest.mock('../../hooks/utils/useGetShortUrl', () => ({ + useGetShortUrl: () => ({ + shareLink: shortLink, + getTrackedUrl: (url: string) => `${url}?cid=share_post`, + }), +})); + +const mockDisplayToast = jest.fn(); +const mockLogEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + // Keep the real module's exports — the component imports `ToastType` from + // here too, and a bare factory would leave it undefined. + ...jest.requireActual('../../hooks/useToastNotification'), + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +// The real Markdown component sanitizes asynchronously and pulls in hover cards +// and the image lightbox; none of that is what these tests are about. +jest.mock('../Markdown', () => ({ + __esModule: true, + // eslint-disable-next-line react/prop-types + default: ({ content }: { content: string }) => ( + // eslint-disable-next-line react/no-danger +
+ ), +})); + +const post = { + id: 'brief-1', + title: 'Presidential briefing', + commentsPermalink: 'https://app.daily.dev/posts/brief-1', +} as Post; + +const contentHtml = ` +

The npm supply chain took another hit

+

Three packages shipped a malicious post-install script.

+ +

Rust 1.90 lands async closures

+

The feature has been in nightly for two years.

+`; + +const renderComponent = (showItemActions = true, html = contentHtml) => + render( + + + , + ); + +// Locate a control by the element it belongs to rather than by index — the DOM +// order of headings, paragraphs and bullets interleaves. +const controlIn = (selector: string, contains: string): HTMLElement => { + const host = Array.from( + document.querySelectorAll(selector), + ).find((element) => element.textContent?.includes(contains)); + + if (!host) { + throw new Error(`no ${selector} containing "${contains}"`); + } + + return host.querySelector('button') as HTMLElement; +}; + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +it('renders a copy control on every heading, bullet and paragraph', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + // 2 bullets + 2 paragraphs. + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4); +}); + +it('copies a single bullet with the briefing link appended', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('li', 'What happened')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `What happened: a maintainer account was taken over.\n\n${shortLink}`, + ), + ); + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Copied to clipboard', + expect.objectContaining({ variant: ToastType.Success }), + ); +}); + +it('copies a standalone paragraph', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('p', 'Three packages')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `Three packages shipped a malicious post-install script.\n\n${shortLink}`, + ), + ); +}); + +it('copies a section as its heading, prose and bullets, one bullet per line', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + fireEvent.click(controlIn('h2', 'npm supply chain')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + [ + 'The npm supply chain took another hit', + '', + 'Three packages shipped a malicious post-install script.', + '', + '- What happened: a maintainer account was taken over.', + '- What to do: rotate your CI credentials.', + '', + shortLink, + ].join('\n'), + ), + ); +}); + +it('stops a section at the next heading', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + fireEvent.click(controlIn('h2', 'Rust 1.90')); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(writeText.mock.calls[0][0]).not.toContain('npm supply chain'); +}); + +// The body is model-generated markdown: heading level and whether a section +// uses bullets or prose vary between briefings. Matching only h2/h3/li left +// briefs like this one with no controls at all. +it('attaches to other heading levels and to prose-only sections', async () => { + renderComponent( + true, + `

Top level

Lead paragraph.

Deep heading

More prose.

`, + ); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + expect(screen.getAllByLabelText('Copy item')).toHaveLength(2); +}); + +it('does not double up on a paragraph nested inside a list item', async () => { + renderComponent(true, ``); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(1), + ); +}); + +it('logs the copy with a content discriminator per item kind', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('li', 'What happened')); + + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + extra: expect.stringContaining(ShareProvider.CopyText), + }), + ); + expect(mockLogEvent.mock.calls[0][0].extra).toContain('brief_bullet'); +}); + +it('confirms with a success checkmark on the control that was clicked', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + const first = controlIn('li', 'What happened'); + const second = controlIn('li', 'What to do'); + fireEvent.click(first); + + // Only the clicked control confirms — the shared `useCopy` flag would have + // lit every control on the page at once. + await waitFor(() => + expect(first.querySelector('.text-status-success')).toBeInTheDocument(), + ); + expect(second.querySelector('.text-status-success')).not.toBeInTheDocument(); +}); + +it('clears the checkmark after the confirmation window', async () => { + jest.useFakeTimers(); + + try { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + const first = controlIn('li', 'What happened'); + + act(() => { + fireEvent.click(first); + }); + expect(first.querySelector('.text-status-success')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(first.querySelector('.text-status-success')).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + +it('renders no controls at all with the gate off', async () => { + renderComponent(false); + + await waitFor(() => + expect(screen.getByText('Rust 1.90 lands async closures')).toBeVisible(), + ); + expect(screen.queryByLabelText('Copy item')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Copy section')).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/brief/BriefContent.tsx b/packages/shared/src/components/brief/BriefContent.tsx new file mode 100644 index 00000000000..f72e03002c5 --- /dev/null +++ b/packages/shared/src/components/brief/BriefContent.tsx @@ -0,0 +1,289 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import Markdown from '../Markdown'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon, VIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import type { Post } from '../../graphql/posts'; +import { useCopyText } from '../../hooks/useCopy'; +import { useCopyFeedback } from '../../hooks/useCopyFeedback'; +import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl'; +import { useActiveFeedContext } from '../../contexts/ActiveFeedContext'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ToastType } from '../../hooks/useToastNotification'; +import { useShareBriefingDigest } from '../../hooks/useShareBriefingDigest'; +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'; + +// The briefing body is sanitized HTML from `Markdown`, so per-item controls +// can't be composed in JSX. A mount node is appended to each bullet and section +// heading instead, and the button is portalled into it. +const MOUNT_CLASS = 'brief-item-copy-mount'; +// Deliberately wide. The body is model-generated markdown, so the heading level +// and whether a section uses bullets or prose vary between briefings — matching +// only `h2`/`h3`/`li` meant a brief written as plain paragraphs got no controls +// at all, which reads as the feature being missing rather than not applicable. +const ITEM_SELECTOR = 'h1, h2, h3, h4, li, p'; +const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4']); + +// A paragraph nested inside a list item or a quote is part of that item, not an +// item of its own — it would get a second, redundant control. +const isNestedProse = (element: HTMLElement): boolean => + element.tagName === 'P' && !!element.closest('li, blockquote'); + +type BriefItemKind = 'bullet' | 'paragraph' | 'section'; + +const kindOf = (element: HTMLElement): BriefItemKind => { + if (HEADING_TAGS.has(element.tagName)) { + return 'section'; + } + + return element.tagName === 'LI' ? 'bullet' : 'paragraph'; +}; + +interface BriefItem { + mount: HTMLElement; + kind: BriefItemKind; + element: HTMLElement; +} + +const readText = (element: Element): string => { + const clone = element.cloneNode(true) as HTMLElement; + clone.querySelectorAll(`.${MOUNT_CLASS}`).forEach((node) => node.remove()); + + return (clone.textContent ?? '').replace(/\s+/g, ' ').trim(); +}; + +// Lists have to be read item by item — collapsing a whole `
- + {isNotPlus && (
({ + 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 both a direct copy-link button and the share popover', () => { + renderBrief(); + + expect(screen.getByLabelText('Share briefing')).toBeInTheDocument(); + + // Copy link keeps its own control rather than hiding inside the popover, + // so the most common action stays one tap. + fireEvent.click(screen.getByLabelText('Copy link')); + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + }); + + 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..5f47be1a2c0 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -6,9 +6,12 @@ import type { PostHeaderActionsProps } from '../common'; import Link from '../../utilities/Link'; import { Button, ButtonSize } 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 { SettingsIcon } from '../../icons'; +import { useShareBriefingDigest } from '../../../hooks/useShareBriefingDigest'; +import { + BriefCopyLinkButton, + BriefShareControls, +} from '../../brief/BriefShareControls'; import type { Origin } from '../../../lib/log'; const Container = classed('div', 'flex flex-row items-center'); @@ -27,18 +30,27 @@ export const BriefPostHeaderActions = ({ origin: Origin; showShareButton?: boolean; }): ReactElement => { - const { copyLink } = useSharePost(origin); - const showCopyIcon = useShareCopyIcon(); + const isShareEnabled = useShareBriefingDigest(); return ( + {/* Rendered outside the laptop-only wrapper: sharing a briefing is at + least as valuable on mobile, where the share arrow taps straight + through to the native sheet. */} + {showShareButton && isShareEnabled && ( + + )}
- {showShareButton && ( -