diff --git a/packages/shared/__tests__/fixture/brief.ts b/packages/shared/__tests__/fixture/brief.ts
new file mode 100644
index 00000000000..5d65da91355
--- /dev/null
+++ b/packages/shared/__tests__/fixture/brief.ts
@@ -0,0 +1,22 @@
+/**
+ * A brief's `contentHtml` as the API renders it: markdown-it over the TLDR and
+ * one `## title` plus a bullet list per section, each bullet closing with a
+ * link to the post it came from, or to a feed of them when there are several.
+ */
+export const briefContentHtml = `
The U.S. government has accused Chinese AI firms of industrial-scale model distillation, while Shopify has acquired Tailwind CSS to ensure its long-term stability.
+
Must know
+
+
US intelligence labels Chinese AI distillation a national security threat: A joint advisory accuses six Chinese firms of systematic distillation campaigns against U.S. frontier models. U.S. labs are now being advised to serve subtly degraded responses to suspected distillers to protect their model weights. Read more
+
Shopify acquires Tailwind Labs to anchor the CSS framework: Tailwind CSS creator Adam Wathan announced that the project and its parent company are joining Shopify to ensure long-term maintenance. Read more
+
Microsoft and Cisco hit by record AI discovered vulnerabilities: Microsoft released a massive patch batch this month, a surge researchers attribute to AI-assisted discovery tools. Read more
+
+
Good to know
+
+
GitHub Copilot for JetBrains adds enterprise sandbox policies: Administrators can now centrally control filesystem and network access for Copilot, overriding local developer configs. Read more
+
`;
+
+/** The same brief when the briefing service sent no Must know section. */
+export const briefContentHtmlWithoutMustKnow = briefContentHtml.replace(
+ /
Must know<\/h2>\n
[\s\S]*?<\/ul>\n/,
+ '',
+);
diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx
index c4e7e582b15..c01f7b8dde8 100644
--- a/packages/shared/src/components/brief/BriefListItem.spec.tsx
+++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx
@@ -1,11 +1,14 @@
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();
jest.mock('../../hooks/useOnPostClick', () => ({
__esModule: true,
@@ -20,6 +23,13 @@ jest.mock('../../hooks/usePlusSubscription', () => ({
usePlusSubscription: () => ({ isPlus: true }),
}));
+jest.mock('../../hooks/useSharePost', () => ({
+ useSharePost: () => ({
+ copyLink: mockCopyLink,
+ openSharePost: mockOpenSharePost,
+ }),
+}));
+
const post = {
id: 'brief-1',
slug: 'brief-1',
@@ -30,13 +40,15 @@ const post = {
const renderComponent = (onClick = jest.fn()) =>
render(
- ,
+
+
+ ,
);
describe('BriefListItem', () => {
@@ -87,4 +99,17 @@ describe('BriefListItem', () => {
expect(mockOnPostClick).toHaveBeenCalledWith({ post });
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});
+
+ it('copies and shares the brief link without opening the brief', () => {
+ const onClick = jest.fn();
+ renderComponent(onClick);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));
+
+ expect(mockCopyLink).toHaveBeenCalledWith({ post });
+ expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
+ expect(onClick).not.toHaveBeenCalled();
+ expect(mockOnPostClick).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx
index 6782d7e6922..c3b8d8cfd68 100644
--- a/packages/shared/src/components/brief/BriefListItem.tsx
+++ b/packages/shared/src/components/brief/BriefListItem.tsx
@@ -11,6 +11,10 @@ import type { PillProps } from '../Pill';
import { Pill } from '../Pill';
import { IconSize } from '../Icon';
import { BriefGradientIcon, LockIcon } from '../icons';
+import { LinkIcon } from '../icons/Link';
+import { ShareIcon } from '../icons/Share';
+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 +26,8 @@ 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';
export type BriefListItemProps = {
className?: string;
@@ -55,6 +61,7 @@ export const BriefListItem = ({
const { isPlus } = usePlusSubscription();
const { logEvent } = useLogContext();
const onPostClick = useOnPostClick({ origin });
+ const { copyLink, isCopying, openSharePost } = useSharePost(origin);
const trackBriefClick = () => {
onPostClick({ post });
@@ -86,14 +93,17 @@ export const BriefListItem = ({
-
-
+ {/* `w-full` would claim the whole card and push the controls past its
+ border. */}
+
+
{title}
@@ -150,6 +160,28 @@ export const BriefListItem = ({
onAuxClick={(event) => event.button === 1 && trackBriefClick()}
/>
+ {/* 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/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx
index 40a15690dec..02eb415f4d9 100644
--- a/packages/shared/src/components/imageShare/SnapshotButton.tsx
+++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx
@@ -28,6 +28,12 @@ export interface SnapshotButtonProps {
target: CaptureTarget;
filename?: string;
label?: string;
+ /**
+ * The name a screen reader announces, when the label alone does not say
+ * what this captures: a body with a control on every paragraph. The visible
+ * label and tooltip stay the label.
+ */
+ ariaLabel?: string;
showLabel?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
@@ -46,6 +52,7 @@ export function SnapshotButton({
target,
filename = 'daily-snapshot',
label = SNAPSHOT_LABEL,
+ ariaLabel,
showLabel = true,
captureOptions,
onCapture,
@@ -122,7 +129,7 @@ export function SnapshotButton({
-
+
+
+
+
+
+
+
{isNotPlus && (
({
+ useSharePost: () => ({
+ copyLink: mockCopyLink,
+ openSharePost: mockOpenSharePost,
+ }),
+}));
+
+const post = { id: 'brief-1', slug: 'brief-1' } as Post;
+
+const renderComponent = (showShareButton = true) =>
+ render(
+
+
+ ,
+ );
+
+describe('BriefPostHeaderActions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows share at every width and leaves the copy link to laptop', () => {
+ renderComponent();
+
+ expect(
+ screen.getByRole('button', { name: 'Share briefing' }),
+ ).not.toHaveClass('hidden');
+ expect(screen.getByRole('button', { name: 'Copy link' })).toHaveClass(
+ 'hidden',
+ 'laptop:flex',
+ );
+ });
+
+ it('renders no share controls where the share button is off', () => {
+ renderComponent(false);
+
+ expect(
+ screen.queryByRole('button', { name: 'Copy link' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Share briefing' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('copies the brief link and opens the share modal', () => {
+ 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..e53d36e70f7 100644
--- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx
+++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx
@@ -4,10 +4,13 @@ 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 { ShareIcon } from '../../icons/Share';
+import { Tooltip } from '../../tooltip/Tooltip';
import { useSharePost } from '../../../hooks/useSharePost';
+import { CopyStateIcon } from '../../share/CopyStateIcon';
import type { Origin } from '../../../lib/log';
const Container = classed('div', 'flex flex-row items-center');
@@ -21,25 +24,50 @@ export const BriefPostHeaderActions = ({
isFixedNavigation,
origin,
showShareButton = false,
+ contextMenuId: _contextMenuId,
...props
}: PostHeaderActionsProps & {
origin: Origin;
showShareButton?: boolean;
}): ReactElement => {
- const { copyLink } = useSharePost(origin);
+ const { copyLink, isCopying, openSharePost } = useSharePost(origin);
return (
-
+ {/* Below laptop the page's own header already has a copy link and a menu
+ with Settings, so only Share joins it there. */}
+
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..a74e6a7b47d
--- /dev/null
+++ b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts
@@ -0,0 +1,132 @@
+import {
+ briefContentHtml,
+ briefContentHtmlWithoutMustKnow,
+} from '../../../__tests__/fixture/brief';
+import {
+ BRIEF_BLOCK_SELECTOR,
+ getBriefBlockLabel,
+ 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('BRIEF_BLOCK_SELECTOR', () => {
+ it('matches every bullet and paragraph in the body', () => {
+ const blocks = render().querySelectorAll(BRIEF_BLOCK_SELECTOR);
+
+ expect(Array.from(blocks, (block) => block.tagName)).toEqual([
+ 'LI',
+ 'LI',
+ 'P',
+ 'LI',
+ ]);
+ });
+
+ it('skips a paragraph that only wraps a list item', () => {
+ const blocks = render(
+ '
Wrapped bullet
',
+ ).querySelectorAll(BRIEF_BLOCK_SELECTOR);
+
+ expect(Array.from(blocks, (block) => block.tagName)).toEqual(['LI']);
+ });
+});
+
+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]).toContain('Postgres keeps eating');
+ });
+
+ it('stops at the next heading', () => {
+ const section = getBriefSection(render(), 'Worth a look');
+
+ expect(section?.blocks).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();
+ expect(
+ getBriefSection(render(briefContentHtmlWithoutMustKnow), 'Must know'),
+ ).toBeNull();
+ });
+
+ it('leaves the link to the sources out of every bullet', () => {
+ const section = getBriefSection(render(briefContentHtml), 'Must know');
+
+ expect(section?.blocks).toHaveLength(3);
+ section?.blocks.forEach((block) => expect(block).not.toMatch(/Read more/));
+ expect(section?.blocks[0]).toMatch(/protect their model weights\.$/);
+ // A bullet backed by several posts links to a feed of them instead.
+ expect(section?.blocks[2]).toMatch(/AI-assisted discovery tools\.$/);
+ });
+});
+
+describe('getBriefBlockLabel', () => {
+ it('names a bullet after its claim', () => {
+ expect(
+ getBriefBlockLabel(
+ 'Shopify acquires Tailwind Labs to anchor the CSS framework: Tailwind CSS creator Adam Wathan announced',
+ ),
+ ).toBe(
+ 'Snapshot: Shopify acquires Tailwind Labs to anchor the CSS framework',
+ );
+ });
+
+ it('cuts a long opening at a word, without the punctuation it ends on', () => {
+ expect(
+ getBriefBlockLabel(
+ 'The U.S. government now officially accuses Chinese AI labs, of industrial-scale model distillation.',
+ ),
+ ).toBe(
+ 'Snapshot: The U.S. government now officially accuses Chinese AI labs…',
+ );
+ });
+});
+
+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..116a91a84cf
--- /dev/null
+++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts
@@ -0,0 +1,112 @@
+import { SNAPSHOT_LABEL } from '../../components/imageShare/SnapshotButton';
+import { truncateAtWord } from '../snapshot/snapshotText';
+
+/**
+ * BriefPostContent renders the body as one ``
+ * blob, with no per-item nodes, so the share controls read its blocks back out
+ * of the rendered DOM: what the reader is actually looking at.
+ */
+
+/** A bullet, or a paragraph that is not the body of one. */
+export const BRIEF_BLOCK_SELECTOR = 'li, :not(li) > p';
+
+/**
+ * The link each bullet closes with, to the post it was written from or to a
+ * feed of the posts when there are several. It is how the reader gets to the
+ * sources, not part of the claim, so a capture of the bullet leaves it out.
+ * `last-of-type` rather than `last-child`: the snapshot slot is appended after
+ * it.
+ */
+export const BRIEF_SOURCE_LINK_SELECTOR = [
+ 'li > a[href*="/posts/"]:last-of-type',
+ 'li > a[href*="/feed-by-ids"]:last-of-type',
+].join(', ');
+
+const LABEL_EXCERPT_LENGTH = 60;
+
+export interface BriefSection {
+ heading: HTMLElement;
+ /** The text of every bullet, or of every block when it has none. */
+ blocks: string[];
+}
+
+const HEADING_SELECTOR = 'h1, h2, h3';
+
+/* textContent, not innerText: innerText needs layout, which jsdom has none of,
+ and the collapsed whitespace is what a card wants anyway. */
+const text = (node: Element) => {
+ const clone = node.cloneNode(true) as Element;
+
+ clone
+ .querySelectorAll(BRIEF_SOURCE_LINK_SELECTOR)
+ .forEach((link) => link.remove());
+
+ return (clone.textContent ?? '').replace(/\s+/g, ' ').trim();
+};
+
+/**
+ * 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: string[] = [];
+ let sibling = heading.nextElementSibling;
+
+ while (sibling && !sibling.matches(HEADING_SELECTOR)) {
+ const nested = sibling.querySelectorAll('li');
+ const nodes = nested.length ? Array.from(nested) : [sibling];
+
+ nodes.forEach((node) => {
+ const value = text(node);
+
+ if (value) {
+ blocks.push(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();
+}
+
+/**
+ * Every block has its own snapshot, so "Snapshot" alone repeats a dozen times
+ * down the brief. The bullet's claim, or the opening of a paragraph, tells a
+ * screen reader which one each button captures.
+ */
+export function getBriefBlockLabel(passage: string): string {
+ const excerpt = truncateAtWord(
+ splitBriefBullet(passage.replace(/\s+/g, ' ')),
+ LABEL_EXCERPT_LENGTH,
+ ).replace(/[\s,.:;]+…$/, '…');
+
+ return `${SNAPSHOT_LABEL}: ${excerpt}`;
+}
diff --git a/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.spec.tsx b/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.spec.tsx
new file mode 100644
index 00000000000..5275768cd35
--- /dev/null
+++ b/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.spec.tsx
@@ -0,0 +1,139 @@
+import React, { useRef } from 'react';
+import { QueryClient } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import { BriefMustKnowSnapshotButton } from './BriefMustKnowSnapshotButton';
+import type { Post } from '../../../graphql/posts';
+import { captureShareImage } from '../../../lib/imageShare/captureShareImage';
+import { copyShareImage } from '../../../lib/imageShare/copyShareImage';
+import { LogEvent, Origin } from '../../../lib/log';
+import { ShareProvider } from '../../../lib/share';
+import { briefContentHtmlWithoutMustKnow } from '../../../../__tests__/fixture/brief';
+
+jest.mock('../../../lib/imageShare/captureShareImage', () => ({
+ captureShareImage: jest.fn(),
+}));
+jest.mock('../../../lib/imageShare/copyShareImage', () => ({
+ copyShareImage: jest.fn(),
+}));
+
+const NAME = 'Snapshot: Must know';
+
+const BODY = `
+
Must know
+
+
Agents are eating dev tools: The shift is accelerating.
+
Postgres keeps eating specialists: One engine, every workload.
+
+
Worth a look
+
A paragraph under the second heading.
+`;
+
+const post = {
+ id: 'brief-1',
+ title: 'Presidential briefing',
+ commentsPermalink: 'https://app.daily.dev/posts/brief-1',
+} as Post;
+
+const logEvent = jest.fn();
+
+const Harness = ({ html }: { html: string }) => {
+ const bodyRef = useRef(null);
+
+ return (
+ <>
+ {/* eslint-disable-next-line react/no-danger */}
+
+
+ >
+ );
+};
+
+const renderComponent = (html: string) => {
+ const client = new QueryClient();
+ const withProviders = (body: string) => (
+
+
+
+ );
+ const { rerender } = render(withProviders(html));
+
+ return { setBody: (next: string) => rerender(withProviders(next)) };
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ (captureShareImage as jest.Mock).mockResolvedValue(new Blob());
+ (copyShareImage as jest.Mock).mockResolvedValue(true);
+});
+
+describe('BriefMustKnowSnapshotButton', () => {
+ it('finds the heading when the body renders after it mounts', async () => {
+ // Markdown sanitizes the body a render late, so the section is not there
+ // yet when this component's effect first runs.
+ const { setBody } = renderComponent('');
+
+ expect(
+ screen.queryByRole('button', { name: NAME }),
+ ).not.toBeInTheDocument();
+
+ setBody(BODY);
+
+ const button = await screen.findByRole('button', { name: NAME });
+ expect(button.closest('h2')).toHaveTextContent('Must know');
+ });
+
+ it('stays away from a brief without the section', () => {
+ renderComponent(briefContentHtmlWithoutMustKnow);
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ // Nothing is left behind on the headings that are there.
+ expect(
+ document.querySelector('[data-brief-section-snapshot]'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('mounts the card only once the button is reached for', async () => {
+ renderComponent(BODY);
+
+ const button = await screen.findByRole('button', { name: NAME });
+ expect(screen.getAllByText('Agents are eating dev tools')).toHaveLength(1);
+
+ fireEvent.pointerEnter(button);
+
+ // The claims, without the evidence behind them or the other section.
+ expect(screen.getAllByText('Agents are eating dev tools')).toHaveLength(2);
+ expect(
+ screen.getAllByText('Postgres keeps eating specialists'),
+ ).toHaveLength(2);
+ expect(screen.queryAllByText('The shift is accelerating.')).toHaveLength(0);
+ expect(
+ screen.getAllByText('A paragraph under the second heading.'),
+ ).toHaveLength(1);
+ });
+
+ it('logs the snapshot under the Must know origin', async () => {
+ renderComponent(BODY);
+
+ const button = await screen.findByRole('button', { name: NAME });
+ fireEvent.pointerEnter(button);
+ fireEvent.click(button);
+
+ await waitFor(() =>
+ expect(logEvent).toHaveBeenCalledWith(
+ expect.objectContaining({ event_name: LogEvent.SharePost }),
+ ),
+ );
+ const [event] = logEvent.mock.calls
+ .map(([call]) => call)
+ .filter((call) => call.event_name === LogEvent.SharePost);
+
+ expect(JSON.parse(event.extra)).toEqual(
+ expect.objectContaining({
+ provider: ShareProvider.Snapshot,
+ origin: Origin.BriefMustKnow,
+ result: 'clipboard',
+ }),
+ );
+ });
+});
diff --git a/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.tsx b/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.tsx
new file mode 100644
index 00000000000..41ccb3686c2
--- /dev/null
+++ b/packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.tsx
@@ -0,0 +1,138 @@
+import type { ReactElement, RefObject } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { ButtonSize, ButtonVariant } from '../../../components/buttons/common';
+import {
+ SNAPSHOT_LABEL,
+ SnapshotButton,
+} from '../../../components/imageShare/SnapshotButton';
+import type { Post } from '../../../graphql/posts';
+import { Origin } from '../../../lib/log';
+import { ListSnapshotCard } from '../../snapshot/ListSnapshotCard';
+import { getSnapshotCaptureOptions } from '../../snapshot/snapshotCapture';
+import { useArmedCard } from '../../snapshot/useArmedCard';
+import { useLogSnapshot } from '../../snapshot/useLogSnapshot';
+import { getBriefSection, splitBriefBullet } from '../briefBodyBlocks';
+
+const SECTION = 'Must know';
+/** As many rows as ListSnapshotCard draws. */
+const MAX_ITEMS = 5;
+const SLOT_ATTRIBUTE = 'data-brief-section-snapshot';
+
+interface Slot {
+ node: HTMLElement;
+ titles: string[];
+}
+
+/**
+ * A snapshot on the Must know heading that captures that section's bullets as
+ * one card. The heading lives in Markdown's rendered HTML, so the button is
+ * portalled into an empty span appended to it, like ParagraphSnapshotButtons.
+ */
+export function BriefMustKnowSnapshotButton({
+ containerRef,
+ post,
+}: {
+ containerRef: RefObject;
+ post: Post;
+}): ReactElement | null {
+ const cardRef = useRef(null);
+ const { isArmed, armProps } = useArmedCard();
+ const logSnapshot = useLogSnapshot(post, Origin.BriefMustKnow);
+ const [slot, setSlot] = useState(null);
+ const posts = post.flags?.posts;
+ const sources = post.flags?.sources;
+
+ useEffect(() => {
+ const container = containerRef.current;
+
+ if (!container) {
+ return undefined;
+ }
+
+ const sync = () => {
+ const section = getBriefSection(container, SECTION);
+ const titles = Array.from(
+ new Set(section?.blocks.map(splitBriefBullet)),
+ ).slice(0, MAX_ITEMS);
+
+ if (!section || !titles.length) {
+ setSlot(null);
+ return;
+ }
+
+ const existing = section.heading.querySelector(
+ `[${SLOT_ATTRIBUTE}]`,
+ );
+ const node = existing ?? document.createElement('span');
+
+ if (!existing) {
+ node.setAttribute(SLOT_ATTRIBUTE, '');
+ section.heading.appendChild(node);
+ }
+
+ // The observer fires on the span this appends and on every render of
+ // the button inside it, so an unchanged section keeps the same state.
+ setSlot((current) =>
+ current?.node === node &&
+ current.titles.join('\n') === titles.join('\n')
+ ? current
+ : { node, titles },
+ );
+ };
+
+ sync();
+
+ // The body is sanitized after the first render, so the heading arrives
+ // later than this effect does.
+ const observer = new MutationObserver(sync);
+ observer.observe(container, { childList: true, subtree: true });
+
+ return () => observer.disconnect();
+ }, [containerRef]);
+
+ if (!slot) {
+ return null;
+ }
+
+ return (
+ <>
+ {createPortal(
+
+ getSnapshotCaptureOptions(cardRef.current)}
+ className="ml-2 align-middle"
+ filename={`daily-brief-${post.id}`}
+ onResult={logSnapshot}
+ showLabel={false}
+ size={ButtonSize.Small}
+ target={cardRef}
+ variant={ButtonVariant.Tertiary}
+ />
+ ,
+ slot.node,
+ )}
+ {isArmed && (
+