diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 605cadf9ddf..5f2a2fe0d0d 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -1,4 +1,6 @@ +import type { ReactElement } from 'react'; import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HighlightGrid } from './HighlightGrid'; @@ -6,6 +8,7 @@ import { HighlightList } from './HighlightList'; jest.mock('../../../lib/constants', () => ({ webappUrl: '/', + isPreviewHost: () => false, })); const highlights = [ @@ -31,9 +34,15 @@ const highlights = [ }, ]; +// The copy-link control reaches for the toast, which reads the query client. +const renderCard = (ui: ReactElement) => + render( + {ui}, + ); + describe('Highlight cards', () => { it('should render the grid card with highlight links', () => { - render(); + renderCard(); expect(screen.getByText('Happening Now')).toBeInTheDocument(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); @@ -55,7 +64,7 @@ describe('Highlight cards', () => { }); it('should render the list card with highlight links', () => { - render(); + renderCard(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); @@ -66,7 +75,7 @@ describe('Highlight cards', () => { const onHighlightClick = jest.fn(); const onReadAllClick = jest.fn(); - render( + renderCard( - highlightId ? `${HIGHLIGHTS_URL}?highlight=${highlightId}` : HIGHLIGHTS_URL; - const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); +export { getHighlightsUrl }; + export const ReadAllHighlightsFooter = ({ highlightId, onClick, @@ -73,18 +72,25 @@ const HighlightRow = ({ return ( onHighlightClick?.(highlight, index + 1)} > {highlight.headline} - + + + + ); @@ -118,7 +124,14 @@ export const HighlightCardContent = ({ > Happening Now - + +
{highlights.map((highlight, index) => ( diff --git a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx new file mode 100644 index 00000000000..7e2543d3023 --- /dev/null +++ b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx @@ -0,0 +1,48 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { LinkIcon } from '../icons'; +import { CopyStateIcon } from '../share/CopyStateIcon'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import { getHighlightsUrl } from '../../lib/links'; +import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { featureHappeningNowShare } from '../../lib/featureManagement'; + +export function CopyHighlightsLink({ + link, + className, + size = ButtonSize.Small, +}: { + link?: string; + className?: string; + size?: ButtonSize; +}): ReactElement | null { + const isEnabled = useSharePlacement({ feature: featureHappeningNowShare }); + // useCopyText, not useCopyLink: the link variant reaches for the shortener, + // which needs an authenticated user, and the page has to work signed out. + const [copied, copyLink] = useCopyText(link ?? getHighlightsUrl()); + + if (!isEnabled) { + return null; + } + + return ( + +
@@ -80,12 +91,33 @@ export const HighlightItem = ({ {expanded && tldr && (
-

{tldr}

- - - Read more - - +

+ {tldr} +

+ {canSnapshot && ( + + )} +
+ + + Read more + + + {canSnapshot && ( + + )} +
)} diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index eb11f9e7566..8e67c815750 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -12,6 +12,7 @@ import { postHighlightsFeedQueryOptions, } from '../../graphql/highlights'; import { Tab, TabContainer } from '../tabs/TabContainer'; +import { CopyHighlightsLink } from './CopyHighlightsLink'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; @@ -173,10 +174,11 @@ export const HighlightsPage = (): ReactElement => { return (
-
-

+
+

Happening Now

+
+ + Icon/Snapshot/Filled + + + + + + + + + + diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx new file mode 100644 index 00000000000..8707b229fad --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const SnapshotIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg new file mode 100644 index 00000000000..af265154e03 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/outlined.svg @@ -0,0 +1,11 @@ + + + Icon/Snapshot/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 52ee9458013..5c1057b1724 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -150,6 +150,7 @@ export * from './Shortcuts'; export * from './Sidebar'; export * from './Sites'; export * from './Slack'; +export * from './Snapshot'; export * from './Sort'; export * from './Source'; export * from './Sparkle'; diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx new file mode 100644 index 00000000000..8b42806ca91 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -0,0 +1,140 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { SnapshotIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; +import type { + CaptureShareImageOptions, + CaptureTarget, +} from '../../lib/imageShare/captureShareImage'; +import { captureShareImage } from '../../lib/imageShare/captureShareImage'; +import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; +import { copyShareImage } from '../../lib/imageShare/copyShareImage'; +import { playShutterSound } from '../../features/snapshot/shutterSound'; +import { getSnapshotCaptureOptions } from '../../features/snapshot/snapshotCapture'; + +const SNAPSHOT_LABEL = 'Snapshot'; + +/** Matches the snapshot-shutter-sweep animation in utilities.css. */ +const SHUTTER_SWEEP_MS = 380; + +export interface SnapshotButtonProps { + target: CaptureTarget; + /** Copied as text beside the image, so a paste carries both halves. */ + link?: string; + filename?: string; + label?: string; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; + /** + * Omit for a designed card: its height is measured at the press instead, + * since a frame that grows to fit only has one once it is mounted. + */ + captureOptions?: CaptureShareImageOptions; + onCapture?: (blob: Blob) => void; +} + +export function SnapshotButton({ + target, + link, + filename = 'daily-snapshot', + label = SNAPSHOT_LABEL, + showLabel = true, + captureOptions, + onCapture, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, + className, +}: SnapshotButtonProps): ReactElement { + const { displayToast } = useToastNotification(); + const [isCapturing, setIsCapturing] = useState(false); + const [isFlashing, setIsFlashing] = useState(false); + const flashTimeout = useRef>(); + + useEffect( + () => () => { + if (flashTimeout.current) { + clearTimeout(flashTimeout.current); + } + }, + [], + ); + + const onSnapshot = useCallback( + async (event: React.MouseEvent) => { + // Every placement sits inside a clickable card, row or link. + event.preventDefault(); + event.stopPropagation(); + playShutterSound(); + setIsFlashing(true); + flashTimeout.current = setTimeout( + () => setIsFlashing(false), + SHUTTER_SWEEP_MS, + ); + setIsCapturing(true); + + try { + const element = target instanceof HTMLElement ? target : target.current; + const capture = captureShareImage( + target, + captureOptions ?? getSnapshotCaptureOptions(element), + ); + + if (onCapture) { + onCapture(await capture); + return; + } + + // Pasting beats a file in Downloads for every target we share to, so + // the clipboard leads and the download is the fallback. + if (await copyShareImage(capture, link)) { + displayToast(link ? 'Image and link copied' : 'Image copied', { + variant: ToastType.Success, + }); + return; + } + + downloadShareImage(await capture, filename); + displayToast('Image saved', { variant: ToastType.Success }); + } catch { + displayToast('Could not create the snapshot, please try again', { + variant: ToastType.Error, + }); + } finally { + setIsCapturing(false); + } + }, + [captureOptions, displayToast, filename, link, onCapture, target], + ); + + return ( + + + + ); +} diff --git a/packages/shared/src/components/share/CopyStateIcon.spec.tsx b/packages/shared/src/components/share/CopyStateIcon.spec.tsx new file mode 100644 index 00000000000..adaa7834668 --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { LinkIcon } from '../icons'; +import { CopyStateIcon } from './CopyStateIcon'; + +const layers = (container: HTMLElement): string[] => { + const grid = container.querySelector('span.inline-grid'); + + if (!grid) { + throw new Error('CopyStateIcon did not render its grid'); + } + + return Array.from(grid.children).map( + (child) => child.getAttribute('class') ?? '', + ); +}; + +describe('CopyStateIcon', () => { + it('rests on the given glyph with the confirmation hidden', () => { + const { container } = render( + , + ); + const [resting, confirmation] = layers(container); + + expect(resting).not.toContain('opacity-0'); + expect(confirmation).toContain('opacity-0'); + }); + + it('swaps to a green confirmation once copied', () => { + const { container } = render(); + const [resting, confirmation] = layers(container); + + expect(resting).toContain('opacity-0'); + expect(confirmation).not.toContain('opacity-0'); + expect(confirmation).toContain('text-status-success'); + }); + + it('keeps both glyphs in one grid cell so the button never resizes', () => { + const { container } = render(); + + layers(container).forEach((className) => { + expect(className).toContain('col-start-1'); + expect(className).toContain('row-start-1'); + }); + }); +}); diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 00000000000..40f1f3cb4dd --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -0,0 +1,52 @@ +import type { ComponentType, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * easeOutExpo — the curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion. + * Both glyphs share one grid cell so the button never resizes mid-swap, and + * the transition collapses to an instant swap under `prefers-reduced-motion`. + */ +export const CopyStateIcon = ({ + copied, + icon: Icon = CopyIcon, + className, + ...props +}: IconProps & { + copied: boolean; + /** What the button rests on — a link glyph where the payload is a URL. */ + icon?: ComponentType; +}): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx b/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx new file mode 100644 index 00000000000..3ec9633044c --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx @@ -0,0 +1,117 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { CopyIcon, LinkIcon } from '../../components/icons'; +import { CopyStateIcon } from '../../components/share/CopyStateIcon'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; +import type { TextSelection } from './useTextSelection'; +import { useTextSelection } from './useTextSelection'; + +const BAR_HEIGHT = 44; +const GAP = 8; +/** Keeps the bar off the viewport edges when the quote runs to the margin. */ +const EDGE = 96; + +const position = (selection: TextSelection) => { + const above = selection.top - BAR_HEIGHT - GAP; + const center = selection.left + selection.width / 2; + + return { + // Below the quote when it starts at the top of the viewport, where there + // is no room above it. + top: above < GAP ? selection.bottom + GAP : above, + left: Math.min( + Math.max(center, EDGE), + globalThis.innerWidth ? globalThis.innerWidth - EDGE : center, + ), + }; +}; + +export interface HighlightSelectionBarProps { + id: string; + link: string; + containerRef: RefObject; +} + +export function HighlightSelectionBar({ + id, + link, + containerRef, +}: HighlightSelectionBarProps): ReactElement | null { + const barRef = useRef(null); + const cardRef = useRef(null); + const selection = useTextSelection(containerRef, true, barRef); + // The card outlives the bar: pressing Snapshot collapses the selection in + // some browsers, and the capture still has to find the quote mounted. + const [quote, setQuote] = useState(null); + const [linkCopied, copyLink] = useCopyText(link); + const [textCopied, copyText] = useCopyText(quote?.text); + + useEffect(() => { + if (selection) { + setQuote(selection); + } + }, [selection]); + + if (!quote || typeof document === 'undefined') { + return null; + } + + return createPortal( + <> + {selection && ( +
+ + +
+ )} + + {/* The card the capture reads from, off-screen at its full 1080px. */} +
+ +
+ , + document.body, + ); +} diff --git a/packages/shared/src/features/snapshot/HighlightShareActions.tsx b/packages/shared/src/features/snapshot/HighlightShareActions.tsx new file mode 100644 index 00000000000..8c2e10aa308 --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button } from '../../components/buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; +import { LinkIcon } from '../../components/icons'; +import { CopyStateIcon } from '../../components/share/CopyStateIcon'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import type { HighlightSnapshotButtonProps } from './HighlightSnapshotButton'; +import { HighlightSnapshotButton } from './HighlightSnapshotButton'; + +type HighlightShareActionsProps = Pick< + HighlightSnapshotButtonProps, + 'id' | 'tldr' | 'source' +> & { + link: string; +}; + +export function HighlightShareActions({ + link, + ...card +}: HighlightShareActionsProps): ReactElement { + // useCopyText, not useCopyLink: the link variant reaches for the shortener, + // which needs an authenticated user, and the page has to work signed out. + const [copied, copyLink] = useCopyText(link); + + return ( + <> + +