From 98d3a332943e3906469fdaedcaa7ad28de21658e Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 18:06:59 +0300 Subject: [PATCH 01/11] feat(snapshot): snapshot an expanded highlight on Happening Now Every highlight is a self-contained claim with sources behind it, and today none of them can be lifted out. The page also has the shortest shelf life we publish, which is why the image beats the link: a URL sends someone to a page that has already moved on. Snapshot and copy link sit opposite Read more once a highlight is open. Expansion is the intent signal, so a scan of the feed stays a scan. Behind snapshot_highlight_expanded, off. The card takes its timestamp from the formatter the row already renders, so the two cannot disagree about how old the claim is, and it no longer prints the channel, which is a slug rather than the tab's display name. /dev/snapshot-happening-now renders the production row with the flag pinned through the harness context, so the review surface exercises the shipped component instead of restating its markup. Mockup-to-eng-pass: 1 --- packages/shared/package.json | 1 + .../highlights/HighlightItem.spec.tsx | 41 +++ .../components/highlights/HighlightItem.tsx | 37 ++- .../src/components/icons/Snapshot/filled.svg | 13 + .../src/components/icons/Snapshot/index.tsx | 10 + .../components/icons/Snapshot/outlined.svg | 11 + packages/shared/src/components/icons/index.ts | 1 + .../components/imageShare/SnapshotButton.tsx | 131 +++++++++ .../snapshot/HighlightShareActions.tsx | 46 +++ .../snapshot/HighlightSnapshotButton.tsx | 71 +++++ .../snapshot/HighlightSnapshotCard.tsx | 106 +++++++ .../src/features/snapshot/SnapshotFrame.tsx | 77 +++++ .../src/features/snapshot/shutterSound.ts | 23 ++ .../src/features/snapshot/snapshotGradient.ts | 71 +++++ .../src/features/snapshot/snapshotText.ts | 22 ++ .../snapshot/useSharePlacement.spec.tsx | 75 +++++ .../features/snapshot/useSharePlacement.ts | 32 +++ packages/shared/src/lib/constants.ts | 15 + packages/shared/src/lib/featureManagement.ts | 6 + .../src/lib/imageShare/captureShareImage.ts | 120 ++++++++ .../src/lib/imageShare/copyShareImage.spec.ts | 57 ++++ .../src/lib/imageShare/copyShareImage.ts | 45 +++ .../src/lib/imageShare/downloadShareImage.ts | 10 + packages/shared/src/styles/utilities.css | 51 ++++ .../pages/dev/snapshot-happening-now.tsx | 265 ++++++++++++++++++ packages/webapp/public/sounds/shutter.mp3 | Bin 0 -> 45824 bytes pnpm-lock.yaml | 8 + 27 files changed, 1339 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/components/icons/Snapshot/filled.svg create mode 100644 packages/shared/src/components/icons/Snapshot/index.tsx create mode 100644 packages/shared/src/components/icons/Snapshot/outlined.svg create mode 100644 packages/shared/src/components/imageShare/SnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightShareActions.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotFrame.tsx create mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/shared/src/features/snapshot/snapshotGradient.ts create mode 100644 packages/shared/src/features/snapshot/snapshotText.ts create mode 100644 packages/shared/src/features/snapshot/useSharePlacement.spec.tsx create mode 100644 packages/shared/src/features/snapshot/useSharePlacement.ts create mode 100644 packages/shared/src/lib/imageShare/captureShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/copyShareImage.spec.ts create mode 100644 packages/shared/src/lib/imageShare/copyShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/downloadShareImage.ts create mode 100644 packages/webapp/pages/dev/snapshot-happening-now.tsx create mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/package.json b/packages/shared/package.json index 00e6ef543b4..02c0664bf98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@zumer/snapdom": "^2.23.1", "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 5f1e186989a..2a7c45f1e12 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,6 +1,10 @@ import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; import type { PostHighlightFeed } from '../../graphql/highlights'; +import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; import { HighlightItem } from './HighlightItem'; const scrollIntoView = jest.fn(); @@ -30,6 +34,19 @@ beforeEach(() => { scrollIntoView.mockClear(); }); +const renderWithSnapshot = (defaultExpanded = false) => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureSnapshotHighlightExpanded.id]: { defaultValue: true }, + }); + + return render( + + + , + ); +}; + describe('HighlightItem', () => { it('should expand when the route-driven default changes after mount', () => { const { rerender } = render(); @@ -45,4 +62,28 @@ describe('HighlightItem', () => { ); expect(scrollIntoView).toHaveBeenCalled(); }); + + it('keeps an expanded highlight free of share controls while the flag is off', () => { + render(); + + expect( + screen.queryByRole('button', { name: /snapshot/i }), + ).not.toBeInTheDocument(); + }); + + it('offers nothing on a collapsed row even with the flag on', () => { + renderWithSnapshot(); + + expect( + screen.queryByRole('button', { name: /snapshot/i }), + ).not.toBeInTheDocument(); + }); + + it('offers snapshot and copy link beside Read more when expanded', () => { + renderWithSnapshot(true); + + expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /copy link/i })).toBeVisible(); + expect(screen.getByRole('link', { name: /read more/i })).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 4256c618b38..51e2b20c635 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -8,6 +8,12 @@ import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; +import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions'; +import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; +import { getLastActivityDateFormat } from '../../lib/dateFormat'; + +const MAX_HOURS_AGO = 72; interface HighlightItemProps { highlight: PostHighlightFeed; @@ -20,6 +26,10 @@ export const HighlightItem = ({ }: HighlightItemProps): ReactElement => { const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); + const canSnapshot = useSharePlacement({ + feature: featureSnapshotHighlightExpanded, + shouldEvaluate: expanded, + }); useEffect(() => { if (defaultExpanded) { @@ -66,7 +76,7 @@ export const HighlightItem = ({ @@ -81,11 +91,26 @@ export const HighlightItem = ({ {expanded && tldr && (

{tldr}

- - - Read more - - +
+ + + Read more + + + {canSnapshot && ( + + )} +
)} diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg new file mode 100644 index 00000000000..d4cc05f0b56 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/filled.svg @@ -0,0 +1,13 @@ + + + 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..d6763584b20 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -0,0 +1,131 @@ +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'; + +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; + 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 capture = captureShareImage(target, captureOptions); + + 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/features/snapshot/HighlightShareActions.tsx b/packages/shared/src/features/snapshot/HighlightShareActions.tsx new file mode 100644 index 00000000000..53273e3855c --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -0,0 +1,46 @@ +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 { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import type { HighlightSnapshotButtonProps } from './HighlightSnapshotButton'; +import { HighlightSnapshotButton } from './HighlightSnapshotButton'; + +type HighlightShareActionsProps = Pick< + HighlightSnapshotButtonProps, + 'id' | 'headline' | 'tldr' | 'meta' +> & { + 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 [, copyLink] = useCopyText(link); + + return ( + <> + + {expanded && tldr && (
-

{tldr}

+

+ {tldr} +

+ {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

+
{ + 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; + headline: string; + link: string; + containerRef: RefObject; +} + +export function HighlightSelectionBar({ + id, + headline, + 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 [, copyLink] = useCopyText(link); + const [, 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 index 53273e3855c..cfa0b6e5b7b 100644 --- a/packages/shared/src/features/snapshot/HighlightShareActions.tsx +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -38,8 +38,9 @@ export function HighlightShareActions({ ); diff --git a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx new file mode 100644 index 00000000000..a6887f0b94b --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotFrame } from './SnapshotFrame'; +import { truncateAtWord } from './snapshotText'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** + * The quote is the whole image, so it takes as much size as it can carry: + * short highlights get set large, longer ones step down rather than clip. + */ +const quoteFontSize = (length: number): number => { + if (length <= 70) { + return 72; + } + + if (length <= 140) { + return 60; + } + + if (length <= 240) { + return 48; + } + + return 40; +}; + +export interface HighlightTextSnapshotCardProps { + text: string; + source?: { name: string; image?: string }; + postTitle?: string; + domain?: string; + seed?: string; +} + +function HighlightTextSnapshotCardComponent( + { text, source, postTitle, domain, seed }: HighlightTextSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const quote = truncateAtWord(text); + const attribution = [postTitle, domain].filter(Boolean).join(' · '); + + return ( + +
+
+ + “ + +

+ {quote} +

+
+ +
+ {source && ( +
+ {source.image && ( + + )} + + {source.name} + +
+ )} + {attribution && ( + + {attribution} + + )} +
+
+
+ ); +} + +export const HighlightTextSnapshotCard = forwardRef( + HighlightTextSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx index 771d6778af6..b8959641043 100644 --- a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx +++ b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx @@ -3,12 +3,12 @@ import { QueryClient } from '@tanstack/react-query'; import { GrowthBook } from '@growthbook/growthbook-react'; import { render, screen } from '@testing-library/react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; -import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; +import { featureHappeningNowShare } from '../../lib/featureManagement'; import { useSharePlacement } from './useSharePlacement'; const Probe = ({ shouldEvaluate }: { shouldEvaluate?: boolean }) => { const enabled = useSharePlacement({ - feature: featureSnapshotHighlightExpanded, + feature: featureHappeningNowShare, shouldEvaluate, }); @@ -32,7 +32,7 @@ const setHostname = (hostname: string) => { const flagOn = () => { const gb = new GrowthBook(); gb.setFeatures({ - [featureSnapshotHighlightExpanded.id]: { defaultValue: true }, + [featureHappeningNowShare.id]: { defaultValue: true }, }); return gb; diff --git a/packages/shared/src/features/snapshot/useTextSelection.ts b/packages/shared/src/features/snapshot/useTextSelection.ts new file mode 100644 index 00000000000..73b5abb82ac --- /dev/null +++ b/packages/shared/src/features/snapshot/useTextSelection.ts @@ -0,0 +1,117 @@ +import type { RefObject } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +export interface TextSelection { + text: string; + /** Viewport coordinates, so a fixed toolbar can use them unchanged. */ + top: number; + bottom: number; + left: number; + width: number; +} + +/** Under this a selection is a stray double-click, not a quote worth sharing. */ +export const MIN_SELECTION_LENGTH = 24; + +/** How long the selection has to hold still before the toolbar commits to it. */ +const SETTLE_MS = 150; + +const read = (container: HTMLElement | null): TextSelection | null => { + const selection = globalThis.getSelection?.(); + + if (!container || !selection || selection.isCollapsed) { + return null; + } + + const text = selection.toString().trim(); + + if (text.length < MIN_SELECTION_LENGTH || selection.rangeCount === 0) { + return null; + } + + const range = selection.getRangeAt(0); + + if (!container.contains(range.commonAncestorContainer)) { + return null; + } + + const rect = range.getBoundingClientRect(); + + if (!rect.width && !rect.height) { + return null; + } + + return { + text, + top: rect.top, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + }; +}; + +/** + * The current selection, but only while it lives inside `containerRef` — a + * quote from the post body, never from the comments or the nav around it. + */ +export function useTextSelection( + containerRef: RefObject, + enabled: boolean, + /** Pointer presses inside this element leave the selection alone, so the + toolbar built on top of it can be clicked. */ + ignoreRef?: RefObject, +): TextSelection | null { + const [selection, setSelection] = useState(null); + + const sync = useCallback( + () => setSelection(read(containerRef.current)), + [containerRef], + ); + + useEffect(() => { + if (!enabled) { + setSelection(null); + return undefined; + } + + let settle: ReturnType; + + // The range grows on every mouse move and a toolbar that chases it is + // unusable, so the trailing edge of the drag is the one that counts. A + // timer rather than a drag flag: a pointerup can be lost to a pointer + // released outside the window, and a flag left raised would strand the + // toolbar for the rest of the page's life. + const onSelectionChange = () => { + clearTimeout(settle); + settle = setTimeout(sync, SETTLE_MS); + }; + const onPointerDown = (event: PointerEvent) => { + if (ignoreRef?.current?.contains(event.target as Node)) { + return; + } + + setSelection(null); + }; + const onPointerUp = () => { + clearTimeout(settle); + sync(); + }; + + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('pointerup', onPointerUp); + globalThis.addEventListener('scroll', sync, { passive: true }); + globalThis.addEventListener('resize', sync); + + return () => { + clearTimeout(settle); + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('pointerup', onPointerUp); + globalThis.removeEventListener('scroll', sync); + globalThis.removeEventListener('resize', sync); + }; + }, [enabled, ignoreRef, sync]); + + return selection; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index f3029d30085..26b587920ad 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -30,10 +30,10 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); -// Snapshot on Happening Now: the expanded highlight is the only level that -// carries it — expansion is the intent signal, and there is room for a label. -export const featureSnapshotHighlightExpanded = new Feature( - 'snapshot_highlight_expanded', +// Every share affordance on Happening Now: snapshot on an expanded highlight, +// the selection bar inside its TLDR, and the copy-link controls. +export const featureHappeningNowShare = new Feature( + 'happening_now_share', false, ); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index fb802ebe610..30dca746b1d 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -186,3 +186,11 @@ export const getRedirectNextPath = (params: URLSearchParams): string => { return checkIsExtension() ? `${webappUrl}${nextPath}` : nextPath; }; + +/* A function, not a constant: `webappUrl` comes from the environment, and a + module-level template literal captures it before a test can set it. */ +export const getHighlightsUrl = (highlightId?: string): string => { + const base = `${webappUrl}highlights`; + + return highlightId ? `${base}?highlight=${highlightId}` : base; +}; diff --git a/packages/webapp/pages/dev/snapshot-happening-now.tsx b/packages/webapp/pages/dev/snapshot-happening-now.tsx index 5e36eb5eaaa..d307633a556 100644 --- a/packages/webapp/pages/dev/snapshot-happening-now.tsx +++ b/packages/webapp/pages/dev/snapshot-happening-now.tsx @@ -9,7 +9,7 @@ import { import { HighlightItem } from '@dailydotdev/shared/src/components/highlights/HighlightItem'; import { HighlightSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotCard'; import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; -import { featureSnapshotHighlightExpanded } from '@dailydotdev/shared/src/lib/featureManagement'; +import { featureHappeningNowShare } from '@dailydotdev/shared/src/lib/featureManagement'; import type { PostHighlightFeed } from '@dailydotdev/shared/src/graphql/highlights'; import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; @@ -110,7 +110,7 @@ const LOG_STUB = { } as unknown as LogContextData; const FORCED: Record = { - [featureSnapshotHighlightExpanded.id]: true, + [featureHappeningNowShare.id]: true, }; /* GrowthBookContext is re-exported for harnesses exactly like this one, so the From da2f4e431fa81bb61f6c751f3f0e6db40167da6b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:09:33 +0300 Subject: [PATCH 03/11] feat(share): confirm a copy on the button that was pressed Every copy control on Happening Now swapped its glyph for nothing: the toast was the only acknowledgement, and it lands in a corner away from the press. The icon now crossfades to a green check and back after a second, so the confirmation arrives where the eye already is. CopyStateIcon comes from the split share button work, widened to take the resting glyph so a link button can use it too. It ships there as well; whichever lands second drops its copy. Mockup-to-eng-pass: 3 --- .../highlights/CopyHighlightsLink.tsx | 5 +- .../components/share/CopyStateIcon.spec.tsx | 46 ++++++++++++++++ .../src/components/share/CopyStateIcon.tsx | 52 +++++++++++++++++++ .../snapshot/HighlightSelectionBar.tsx | 9 ++-- .../snapshot/HighlightShareActions.tsx | 5 +- 5 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.spec.tsx create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx diff --git a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx index 3461dbebd56..7e2543d3023 100644 --- a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx +++ b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx @@ -2,6 +2,7 @@ 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'; @@ -20,7 +21,7 @@ export function CopyHighlightsLink({ 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 [, copyLink] = useCopyText(link ?? getHighlightsUrl()); + const [copied, copyLink] = useCopyText(link ?? getHighlightsUrl()); if (!isEnabled) { return null; @@ -31,7 +32,7 @@ export function CopyHighlightsLink({

, document.body, diff --git a/packages/shared/src/features/snapshot/HighlightShareActions.tsx b/packages/shared/src/features/snapshot/HighlightShareActions.tsx index 35b7e67aa27..8c2e10aa308 100644 --- a/packages/shared/src/features/snapshot/HighlightShareActions.tsx +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -11,7 +11,7 @@ import { HighlightSnapshotButton } from './HighlightSnapshotButton'; type HighlightShareActionsProps = Pick< HighlightSnapshotButtonProps, - 'id' | 'headline' | 'tldr' | 'meta' + 'id' | 'tldr' | 'source' > & { link: string; }; diff --git a/packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx b/packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx index b83cc35d8be..e1e5d3860ba 100644 --- a/packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx @@ -7,13 +7,6 @@ import type { import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; import type { HighlightSnapshotCardProps } from './HighlightSnapshotCard'; import { HighlightSnapshotCard } from './HighlightSnapshotCard'; -import { SNAPSHOT_SIZE } from './snapshotGradient'; - -const CAPTURE_OPTIONS = { - width: SNAPSHOT_SIZE, - height: SNAPSHOT_SIZE, - padding: 0, -}; export interface HighlightSnapshotButtonProps extends Omit { @@ -31,9 +24,8 @@ export interface HighlightSnapshotButtonProps */ export function HighlightSnapshotButton({ id, - headline, tldr, - meta, + source, link, showLabel, size, @@ -45,7 +37,6 @@ export function HighlightSnapshotButton({ return ( <>
diff --git a/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx index 52c56a7bd0c..2bb42817123 100644 --- a/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx @@ -1,102 +1,66 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; -import colors from '../../styles/colors'; +import { SnapshotCredit } from './SnapshotCredit'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; import { SnapshotFrame } from './SnapshotFrame'; -import { truncateAtWord } from './snapshotText'; - -const MUTED = colors.salt['90']; -const DIVIDER = colors.pepper['10']; - -const TLDR_LIMIT = 220; - -/** Longer headlines step down rather than push the TLDR off the edge. */ -const headlineFontSize = (length: number): number => { - if (length <= 50) { - return 64; - } - - if (length <= 90) { - return 54; - } - - if (length <= 140) { - return 44; - } - - return 38; -}; +import { HIGHLIGHTS_EYEBROW_GRADIENT } from './snapshotGradient'; +import { + SNAPSHOT_COPY_SIZE, + SNAPSHOT_PASSAGE_LIMIT, + truncateAtWord, +} from './snapshotText'; export interface HighlightSnapshotCardProps { - headline: string; - tldr?: string; - /** The same relative time the row shows, e.g. "2h ago". */ - meta?: string; + /** The TLDR, and the whole subject of the card. */ + tldr: string; + /** Credits the publication the claim came from, where the feed knows it. */ + source?: { name: string; image?: string }; seed?: string; } +/** + * The claim in white under the Happening Now wordmark, credited to its source. + * The headline is left off: the TLDR already says what it says, at more + * length, and two statements of the same fact compete for the same glance. + */ function HighlightSnapshotCardComponent( - { headline, tldr, meta, seed }: HighlightSnapshotCardProps, + { tldr, source, seed }: HighlightSnapshotCardProps, ref: React.Ref, ): ReactElement { - const summary = tldr ? truncateAtWord(tldr, TLDR_LIMIT) : ''; + const copy = truncateAtWord(tldr, SNAPSHOT_PASSAGE_LIMIT); return ( - + + } + ref={ref} + seed={seed ?? tldr} + wide + >
-
- - +

- Happening now - -

- -

- {headline} -

- - {summary && ( -

- {summary} + {copy}

- )} +
- {meta && ( - - {meta} - + {source?.name && ( + )}
diff --git a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx index a6887f0b94b..32296539bf1 100644 --- a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx @@ -1,103 +1,52 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; -import colors from '../../styles/colors'; +import { SnapshotCredit } from './SnapshotCredit'; import { SnapshotFrame } from './SnapshotFrame'; -import { truncateAtWord } from './snapshotText'; - -const MUTED = colors.salt['90']; -const DIVIDER = colors.pepper['10']; - -/** - * The quote is the whole image, so it takes as much size as it can carry: - * short highlights get set large, longer ones step down rather than clip. - */ -const quoteFontSize = (length: number): number => { - if (length <= 70) { - return 72; - } - - if (length <= 140) { - return 60; - } - - if (length <= 240) { - return 48; - } - - return 40; -}; +import { + SNAPSHOT_COPY_SIZE, + SNAPSHOT_PASSAGE_LIMIT, + truncateAtWord, +} from './snapshotText'; export interface HighlightTextSnapshotCardProps { + /** What the reader marked, and the whole subject of the card. */ text: string; source?: { name: string; image?: string }; - postTitle?: string; - domain?: string; seed?: string; } +/** + * The reader's selection, set like the post card's TLDR: same copy scale, + * same credit. Nothing around the selection is carried — what was marked is + * what gets sent, so the card needs no highlight of its own. The source is + * named, not linked: a URL is unreadable at a glance and unclickable in an + * image. + */ function HighlightTextSnapshotCardComponent( - { text, source, postTitle, domain, seed }: HighlightTextSnapshotCardProps, + { text, source, seed }: HighlightTextSnapshotCardProps, ref: React.Ref, ): ReactElement { - const quote = truncateAtWord(text); - const attribution = [postTitle, domain].filter(Boolean).join(' · '); + const quote = truncateAtWord(text, SNAPSHOT_PASSAGE_LIMIT); return ( - +
- - “ -

{quote}

-
- {source && ( -
- {source.image && ( - - )} - - {source.name} - -
- )} - {attribution && ( - - {attribution} - - )} -
+ {source?.name && ( + + )}
); diff --git a/packages/shared/src/features/snapshot/SnapshotCredit.tsx b/packages/shared/src/features/snapshot/SnapshotCredit.tsx new file mode 100644 index 00000000000..d5d0badd1c8 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotCredit.tsx @@ -0,0 +1,47 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +export interface SnapshotCreditProps { + name: string; + image?: string; +} + +/** + * Who the copy came from, under a rule. The post and highlight cards sit next + * to each other wherever this feature is reviewed, so they credit their source + * from one component rather than two that drift. + * + * Just the name: a date or a URL alongside it reads as a second fact competing + * with the first, and neither is why anyone opens a shared image. + */ +export function SnapshotCredit({ + name, + image, +}: SnapshotCreditProps): ReactElement { + return ( +
+ {image && ( + + )} + + {name} + +
+ ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx new file mode 100644 index 00000000000..64797e2650b --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +export interface SnapshotEyebrowProps { + label: string; + /** Paints the label with the surface's own wordmark gradient. */ + gradient?: string; +} + +/** + * Which part of the product the card came from. It rides the logo row rather + * than the copy: it is a sibling of the mark, not a headline for the text + * under it. + */ +export function SnapshotEyebrow({ + label, + gradient, +}: SnapshotEyebrowProps): ReactElement { + return ( + + {label} + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx index c03d2502bf6..73c28e15b51 100644 --- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -1,13 +1,26 @@ import type { ReactElement, ReactNode } from 'react'; import React, { forwardRef } from 'react'; +import classNames from 'classnames'; import LogoIcon from '../../svg/LogoIcon'; import LogoText from '../../svg/LogoText'; -import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; +import { + getSnapshotGradient, + SNAPSHOT_MAX_HEIGHT, + SNAPSHOT_SIZE, +} from './snapshotGradient'; export const SNAPSHOT_CARD_SIZE = 780; +/** + * A page-shaped card: the gradient stays as a border rather than a stage, so + * the copy gets the room instead. Surfaces where the text *is* the payload use + * it — a wide margin around a cramped article is space spent on nothing. + */ +export const SNAPSHOT_CARD_WIDE = 1008; const CARD_RADIUS = 48; const CARD_EDGE = 2; +const CARD_PADDING = 58; +const CARD_PADDING_WIDE = 32; /** * The App Store device frame: a lit hairline that is brightest along the top @@ -21,13 +34,31 @@ const CARD_GLOW = interface SnapshotFrameProps { seed: string; + /** + * Sits on the logo row, far right — for a surface label that belongs with + * the mark rather than with the copy. + */ + logoAside?: ReactNode; + /** + * Let the height follow the content instead of holding 1:1. Text surfaces + * use it so the image can carry more than a screenshot would; it still + * starts at the square and stops at SNAPSHOT_MAX_HEIGHT. + */ + grow?: boolean; + /** + * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces + * whose copy needs the room more than the frame needs the margin. + */ + wide?: boolean; children: ReactNode; } function SnapshotFrameComponent( - { seed, children }: SnapshotFrameProps, + { seed, logoAside, grow, wide, children }: SnapshotFrameProps, ref: React.Ref, ): ReactElement { + const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE; + const gutter = (SNAPSHOT_SIZE - cardWidth) / 2; const logo = (
@@ -35,20 +66,41 @@ function SnapshotFrameComponent(
); + const logoRow = logoAside ? ( +
+ {logo} + {logoAside} +
+ ) : ( + logo + ); + return (
- {logo} + {logoRow} {children}
diff --git a/packages/shared/src/features/snapshot/snapshotCapture.ts b/packages/shared/src/features/snapshot/snapshotCapture.ts new file mode 100644 index 00000000000..4effa796a18 --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotCapture.ts @@ -0,0 +1,21 @@ +import type { CaptureShareImageOptions } from '../../lib/imageShare/captureShareImage'; +import { SNAPSHOT_MAX_HEIGHT, SNAPSHOT_SIZE } from './snapshotGradient'; + +/** + * A designed card is 1080 wide and carries its own logo, so the capture only + * has to match its height. Growing cards are measured rather than assumed, in + * both directions: assuming the square would letterbox a long passage down to + * a screenshot's worth of text, and pad a short one out with dead gradient. + * An unmeasurable element falls back to the square. + */ +export function getSnapshotCaptureOptions( + element?: HTMLElement | null, +): CaptureShareImageOptions { + const measured = Math.round(element?.getBoundingClientRect().height ?? 0); + + return { + width: SNAPSHOT_SIZE, + height: measured ? Math.min(SNAPSHOT_MAX_HEIGHT, measured) : SNAPSHOT_SIZE, + padding: 0, + }; +} diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts index 03059ea599c..1f967e9bd23 100644 --- a/packages/shared/src/features/snapshot/snapshotGradient.ts +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -1,4 +1,18 @@ +import colors from '../../styles/colors'; + export const SNAPSHOT_SIZE = 1080; +/** + * 9:16 — the tallest frame every share destination still shows whole. Text + * surfaces grow into it instead of clamping their copy to the square. + */ +export const SNAPSHOT_MAX_HEIGHT = 1920; + +/** + * The production "Happening Now" wordmark animates across + * blueCheese -> cheese -> avocado. A still frame has to pick a position, and + * the yellow-to-green end is the one the brand shots use. + */ +export const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; /* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined in terms of integer bit operations; expressing them any other way would diff --git a/packages/shared/src/features/snapshot/snapshotText.ts b/packages/shared/src/features/snapshot/snapshotText.ts index 8a629d37120..1e9e1e78155 100644 --- a/packages/shared/src/features/snapshot/snapshotText.ts +++ b/packages/shared/src/features/snapshot/snapshotText.ts @@ -1,6 +1,19 @@ /** The most that still sets legibly inside the square. */ const SNAPSHOT_TEXT_LIMIT = 280; +/** + * The frame grows to fit, so the ceiling on a shared passage is about + * legibility at 1080 wide rather than about the square. + */ +export const SNAPSHOT_PASSAGE_LIMIT = 900; + +/** + * One size for the copy, not a scale. The frames grow to fit, so type no + * longer has to shrink to reach the bottom of a fixed square — and a shared + * image that changes size with its length reads as two different cards. + */ +export const SNAPSHOT_COPY_SIZE = 38; + export function truncateAtWord( text: string, limit = SNAPSHOT_TEXT_LIMIT, diff --git a/packages/webapp/pages/dev/snapshot-happening-now.tsx b/packages/webapp/pages/dev/snapshot-happening-now.tsx index d307633a556..90d83da5e86 100644 --- a/packages/webapp/pages/dev/snapshot-happening-now.tsx +++ b/packages/webapp/pages/dev/snapshot-happening-now.tsx @@ -217,35 +217,27 @@ const SnapshotHappeningNowDevPage = (): ReactElement => {
-
+
{HIGHLIGHTS.map((highlight) => (
- {highlight.headline.length} characters + {highlight.post.summary?.length} characters
-
+ {/* zoom, not transform: a transformed card leaves its + full height behind in the layout, and these grow. + It sits inside the width rather than on it, since + zoom scales the element's own box too. */} +
From 1475a5dc340eee9dfd2b1f0d9c22d39d4ce71d04 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:07 +0300 Subject: [PATCH 05/11] refactor(snapshot): build the Happening Now placement on main's snapshot parts The branch carried its own copies of what #6556 and #6544 shipped: a second selection bar, a second highlight card and a card-mounting button. The placement now runs on main's versions. - The expanded row's Snapshot mounts its card through useArmedCard, on hover, touch or focus, instead of one off-screen 1080px card per expanded highlight at render. The card is HighlightTextSnapshotCard with the "Happening now" eyebrow on its logo row, and the wordmark gradient lives next to it in shared (Storybook keeps its own copy). - The quote bar over the TLDR is main's selection bar. Its body is SelectionShareBar now, taking the link, seed, source and a share callback, and SelectionSnapshotBar wraps it for posts, so the post page keeps its API and events unchanged. - The clipboard gets the image only, as #6556 decided; the old button wrote the link beside it. - Copy link on the expanded row goes through useCopyPostLink with `shorten`, like the post page's copy placements, so the write stays in the gesture and signed-in shares carry the referral campaign. - Every action now logs `SharePost` on the highlight's post with `provider`, a distinct `origin` and the highlight id (plus `result` for a snapshot). Nothing was logged before. New origins: `happening now highlight` for the row and `happening now selection` for the quote bar. - The flag is read with useConditionalFeature, evaluated only once a row is expanded. --- .../highlights/HighlightItem.spec.tsx | 32 ++++- .../components/highlights/HighlightItem.tsx | 22 +--- .../snapshot/HighlightSelectionBar.tsx | 117 ------------------ .../snapshot/HighlightShareActions.tsx | 114 +++++++++++++---- .../snapshot/HighlightSnapshotButton.tsx | 61 --------- .../snapshot/HighlightSnapshotCard.tsx | 70 ----------- .../snapshot/SelectionSnapshotBar.tsx | 101 +++++++++------ .../features/snapshot/useLogHighlightShare.ts | 37 ++++++ packages/shared/src/lib/log.ts | 5 + 9 files changed, 232 insertions(+), 327 deletions(-) delete mode 100644 packages/shared/src/features/snapshot/HighlightSelectionBar.tsx delete mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx delete mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/useLogHighlightShare.ts diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 07898cc9976..cc5f3b4b874 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { QueryClient } from '@tanstack/react-query'; import { GrowthBook } from '@growthbook/growthbook-react'; -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; import type { PostHighlightFeed } from '../../graphql/highlights'; import { featureHappeningNowShare } from '../../lib/featureManagement'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; import { HighlightItem } from './HighlightItem'; const scrollIntoView = jest.fn(); @@ -34,14 +36,14 @@ beforeEach(() => { scrollIntoView.mockClear(); }); -const renderWithSnapshot = (defaultExpanded = false) => { +const renderWithSnapshot = (defaultExpanded = false, logEvent = jest.fn()) => { const gb = new GrowthBook(); gb.setFeatures({ [featureHappeningNowShare.id]: { defaultValue: true }, }); return render( - + , ); @@ -86,4 +88,28 @@ describe('HighlightItem', () => { expect(screen.getByRole('button', { name: /copy link/i })).toBeVisible(); expect(screen.getByRole('link', { name: /read more/i })).toBeVisible(); }); + + it('logs a copied link as a share of the highlighted post', async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + const logEvent = jest.fn(); + renderWithSnapshot(true, logEvent); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /copy link/i })); + }); + + const [[event]] = logEvent.mock.calls; + expect(event).toMatchObject({ + event_name: LogEvent.SharePost, + target_id: 'post-1', + target_type: TargetType.Post, + }); + expect(JSON.parse(event.extra)).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.HappeningNowHighlight, + highlight_id: 'highlight-1', + }); + }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index a0004227dff..713cef0349b 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -8,13 +8,10 @@ import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; -import { HighlightSelectionBar } from '../../features/snapshot/HighlightSelectionBar'; import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions'; -import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { featureHappeningNowShare } from '../../lib/featureManagement'; -const MAX_HOURS_AGO = 72; - interface HighlightItemProps { highlight: PostHighlightFeed; defaultExpanded?: boolean; @@ -27,7 +24,7 @@ export const HighlightItem = ({ const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); const tldrRef = useRef(null); - const canSnapshot = useSharePlacement({ + const { value: canShare } = useConditionalFeature({ feature: featureHappeningNowShare, shouldEvaluate: expanded, }); @@ -77,7 +74,7 @@ export const HighlightItem = ({
@@ -97,24 +94,17 @@ export const HighlightItem = ({ > {tldr}

- {canSnapshot && ( - - )}
Read more - {canSnapshot && ( + {canShare && ( )}
diff --git a/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx b/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx deleted file mode 100644 index 3ec9633044c..00000000000 --- a/packages/shared/src/features/snapshot/HighlightSelectionBar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 index 8c2e10aa308..e70c2c06c71 100644 --- a/packages/shared/src/features/snapshot/HighlightShareActions.tsx +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -1,28 +1,68 @@ -import type { ReactElement } from 'react'; -import React from 'react'; +import type { ReactElement, RefObject } from 'react'; +import React, { useCallback, useRef } from 'react'; import { Button } from '../../components/buttons/Button'; import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; -import { LinkIcon } from '../../components/icons'; +import { LinkIcon } from '../../components/icons/Link'; +import type { SnapshotResult } from '../../components/imageShare/SnapshotButton'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; 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'; +import type { PostHighlightFeed } from '../../graphql/highlights'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { Origin } from '../../lib/log'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ShareProvider } from '../../lib/share'; +import colors from '../../styles/colors'; +import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; +import { SelectionShareBar } from './SelectionSnapshotBar'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { getSnapshotCaptureOptions } from './snapshotCapture'; +import { useArmedCard } from './useArmedCard'; +import { useLogHighlightShare } from './useLogHighlightShare'; -type HighlightShareActionsProps = Pick< - HighlightSnapshotButtonProps, - 'id' | 'tldr' | 'source' -> & { - link: string; -}; +/** + * The production "Happening Now" wordmark animates across + * blueCheese -> cheese -> avocado. A still frame has to pick a position, and + * the yellow-to-green end is the one the brand shots use. + */ +const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; +/** + * Copy link and Snapshot for an expanded highlight, plus the quote bar over + * its TLDR. Kept out of HighlightItem so the row itself needs no query client + * or log context for a placement that is off by default. + */ 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); + highlight, + tldr, + tldrRef, +}: { + highlight: PostHighlightFeed; + tldr: string; + tldrRef: RefObject; +}): ReactElement { + const cardRef = useRef(null); + const { isArmed, armProps } = useArmedCard(); + const [copied, copyLink] = useCopyPostLink(); + const logShare = useLogHighlightShare( + Origin.HappeningNowHighlight, + highlight, + ); + const logSelectionShare = useLogHighlightShare( + Origin.HappeningNowSelection, + highlight, + ); + const link = highlight.post.commentsPermalink; + + const onCopyLink = () => { + logShare(ShareProvider.CopyLink); + copyLink({ link, shorten: true, cid: ReferralCampaignKey.SharePost }); + }; + + const onSnapshot = useCallback( + (result: SnapshotResult) => logShare(ShareProvider.Snapshot, result), + [logShare], + ); return ( <> @@ -30,18 +70,44 @@ export function HighlightShareActions({
, document.body, ); } + +export function SelectionSnapshotBar({ + post, + containerRef, +}: { + post: Post; + containerRef: RefObject; +}): ReactElement { + const { logEvent } = useLogContext(); + + const onShare = useCallback( + (provider: ShareProvider, result?: SnapshotResult) => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { + provider, + origin: Origin.TextSelection, + ...(result && { result }), + }, + }), + ), + [logEvent, post], + ); + + return ( + + ); +} diff --git a/packages/shared/src/features/snapshot/useLogHighlightShare.ts b/packages/shared/src/features/snapshot/useLogHighlightShare.ts new file mode 100644 index 00000000000..6adb4a4f876 --- /dev/null +++ b/packages/shared/src/features/snapshot/useLogHighlightShare.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; +import type { SnapshotResult } from '../../components/imageShare/SnapshotButton'; +import { useLogContext } from '../../contexts/LogContext'; +import type { PostHighlight } from '../../graphql/highlights'; +import type { Origin } from '../../lib/log'; +import { LogEvent, TargetType } from '../../lib/log'; +import type { ShareProvider } from '../../lib/share'; + +/** + * A highlight points at a post, so sharing one is a `SharePost` on that post + * with the highlight id beside it, in the same shape as the post page's + * placements. Without a highlight the link is the Happening Now page itself, + * which has no post to target. + */ +export function useLogHighlightShare( + origin: Origin, + highlight?: Pick, +): (provider: ShareProvider, result?: SnapshotResult) => void { + const { logEvent } = useLogContext(); + const highlightId = highlight?.id; + const postId = highlight?.post.id; + + return useCallback( + (provider: ShareProvider, result?: SnapshotResult) => + logEvent({ + event_name: postId ? LogEvent.SharePost : LogEvent.ShareHighlights, + ...(postId && { target_id: postId, target_type: TargetType.Post }), + extra: JSON.stringify({ + provider, + origin, + ...(result && { result }), + ...(highlightId && { highlight_id: highlightId }), + }), + }), + [highlightId, logEvent, origin, postId], + ); +} diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index bf644bf2bb5..1487e2baabe 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -59,6 +59,10 @@ export enum Origin { PostParagraph = 'post paragraph', PollResults = 'poll results', PollVotePrompt = 'poll vote prompt', + HappeningNow = 'happening now', + HappeningNowHighlight = 'happening now highlight', + HappeningNowSelection = 'happening now selection', + HighlightsCard = 'highlights card', // snapshot placements - end History = 'history', FeedbackCard = 'feedback card', @@ -361,6 +365,7 @@ export enum LogEvent { ShareLog = 'share log', ShareWorld = 'share world', ShareTool = 'share tool', + ShareHighlights = 'share highlights', // End Share /* Start World `world view` is the denominator and fires whatever happens next, so the From 83c1c0a76715291541e8ad848ca0d43623226c84 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:25 +0300 Subject: [PATCH 06/11] fix(highlights): keep the Happening Now headers intact and log their copy links - With the flag off, the copy link rendered nothing but still owned the `ml-auto` that the feed card's options menu used to carry, so the menu slid left against the title for everyone. The menu keeps `ml-auto` unless the copy link is there to take it. - The page title took `flex-1` to push the copy link right. Its wordmark gradient is sized to the element's box, so a full-width h1 showed only the blue end of it, again for everyone. The title keeps its width and the copy link takes `ml-auto`. - The card's hover-revealed copy links were only transparent, so on touch screens they were invisible buttons that swallowed taps on the header and on each row's timestamp. They take pointer events only while the card or row is hovered; focus still reveals them for keyboard users. - The flag is read once per card and once per page with useConditionalFeature, and a disabled placement renders nothing, so the card no longer needs a query client when the flag is off. - Each copy now logs. A row's link is a share of that highlight's post (`SharePost` with the highlight id); the page link has no post, so it logs `ShareHighlights`. Origins: `highlights card` for the feed card, `happening now` for the page header. - getHighlightsUrl moved to lib/links for the copy link; the card module no longer re-exports it and its one other caller imports it directly. --- .../cards/highlight/HighlightCards.spec.tsx | 91 ++++++++++++++++--- .../highlight/HighlightPostSidebarWidget.tsx | 3 +- .../src/components/cards/highlight/common.tsx | 43 ++++++--- .../highlights/CopyHighlightsLink.tsx | 34 +++---- .../components/highlights/HighlightsPage.tsx | 17 +++- 5 files changed, 139 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 5f2a2fe0d0d..c4ece554ac5 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -1,14 +1,17 @@ -import type { ReactElement } from 'react'; import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { featureHappeningNowShare } from '../../../lib/featureManagement'; +import { LogEvent, Origin, TargetType } from '../../../lib/log'; +import { ShareProvider } from '../../../lib/share'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; jest.mock('../../../lib/constants', () => ({ webappUrl: '/', - isPreviewHost: () => false, })); const highlights = [ @@ -34,15 +37,9 @@ 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', () => { - renderCard(); + render(); expect(screen.getByText('Happening Now')).toBeInTheDocument(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); @@ -64,7 +61,7 @@ describe('Highlight cards', () => { }); it('should render the list card with highlight links', () => { - renderCard(); + render(); expect(screen.getByText('The first highlight')).toBeInTheDocument(); expect(screen.getByText('The second highlight')).toBeInTheDocument(); @@ -75,7 +72,7 @@ describe('Highlight cards', () => { const onHighlightClick = jest.fn(); const onReadAllClick = jest.fn(); - renderCard( + render( { expect(onReadAllClick).toHaveBeenCalledTimes(1); }); }); + +describe('Highlight card share controls', () => { + beforeAll(() => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + }); + + const renderShareable = ( + logEvent: jest.Mock, + onHighlightClick?: jest.Mock, + ) => { + const gb = new GrowthBook(); + gb.setFeatures({ [featureHappeningNowShare.id]: { defaultValue: true } }); + + render( + + + , + ); + }; + + it('copies a highlight from its row without opening it', async () => { + const logEvent = jest.fn(); + const onHighlightClick = jest.fn(); + renderShareable(logEvent, onHighlightClick); + // The header's page link comes first, then one per row. + const [, firstRow] = screen.getAllByRole('button', { name: 'Copy link' }); + + await act(async () => { + fireEvent.click(firstRow); + }); + + expect(onHighlightClick).not.toHaveBeenCalled(); + const [[event]] = logEvent.mock.calls; + expect(event).toMatchObject({ + event_name: LogEvent.SharePost, + target_id: 'post-1', + target_type: TargetType.Post, + }); + expect(JSON.parse(event.extra)).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.HighlightsCard, + highlight_id: 'highlight-1', + }); + }); + + it('logs the header link as a share of the page', async () => { + const logEvent = jest.fn(); + renderShareable(logEvent); + const [header] = screen.getAllByRole('button', { name: 'Copy link' }); + + await act(async () => { + fireEvent.click(header); + }); + + const [[event]] = logEvent.mock.calls; + expect(event.event_name).toBe(LogEvent.ShareHighlights); + expect(event.target_id).toBeUndefined(); + expect(JSON.parse(event.extra)).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.HighlightsCard, + }); + }); +}); diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx index 8717308efd0..94d56ad6902 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -3,7 +3,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { useQuery } from '@tanstack/react-query'; import { WidgetContainer } from '../../widgets/common'; -import { getHighlightsUrl, highlightsTitleGradientClassName } from './common'; +import { highlightsTitleGradientClassName } from './common'; +import { getHighlightsUrl } from '../../../lib/links'; import { majorHeadlinesQueryOptions, type PostHighlight, diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index 1690ecd5c50..e398165a358 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -7,6 +7,9 @@ import { RelativeTime } from '../../utilities/RelativeTime'; import Link from '../../utilities/Link'; import { ButtonSize } from '../../buttons/common'; import { CopyHighlightsLink } from '../../highlights/CopyHighlightsLink'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureHappeningNowShare } from '../../../lib/featureManagement'; +import { Origin } from '../../../lib/log'; import { HighlightCardOptions } from './HighlightCardOptions'; export interface HighlightCardProps { @@ -21,8 +24,6 @@ export const highlightsTitleGradientClassName = const getHighlightUrl = (highlight: PostHighlight): string => getHighlightsUrl(highlight.id); -export { getHighlightsUrl }; - export const ReadAllHighlightsFooter = ({ highlightId, onClick, @@ -64,10 +65,12 @@ const HighlightRow = ({ highlight, index, onHighlightClick, + canShare, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; + canShare: boolean; }): ReactElement => { return ( @@ -85,11 +88,14 @@ const HighlightRow = ({ maxHoursAgo={72} className="text-text-tertiary typo-footnote" /> - + {canShare && ( + + )} @@ -112,6 +118,9 @@ export const HighlightCardContent = ({ : 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto px-2.5 pb-1 pt-0'; const footerClassName = variant === 'list' ? 'pt-1.5' : 'px-1 pb-1'; const firstHighlight = highlights[0]; + const { value: canShare } = useConditionalFeature({ + feature: featureHappeningNowShare, + }); return ( <> @@ -124,14 +133,17 @@ export const HighlightCardContent = ({ > Happening Now - - + {canShare && ( + + )} +
{highlights.map((highlight, index) => ( @@ -140,6 +152,7 @@ export const HighlightCardContent = ({ highlight={highlight} index={index} onHighlightClick={onHighlightClick} + canShare={canShare} /> ))}
diff --git a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx index 7e2543d3023..07f4bf6b2c6 100644 --- a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx +++ b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx @@ -1,31 +1,30 @@ import type { MouseEvent, ReactElement } from 'react'; import React from 'react'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { LinkIcon } from '../icons'; +import { LinkIcon } from '../icons/Link'; import { CopyStateIcon } from '../share/CopyStateIcon'; import { Tooltip } from '../tooltip/Tooltip'; -import { useCopyText } from '../../hooks/useCopy'; +import { useCopyLink } from '../../hooks/useCopy'; +import type { PostHighlight } from '../../graphql/highlights'; +import type { Origin } from '../../lib/log'; import { getHighlightsUrl } from '../../lib/links'; -import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; -import { featureHappeningNowShare } from '../../lib/featureManagement'; +import { ShareProvider } from '../../lib/share'; +import { useLogHighlightShare } from '../../features/snapshot/useLogHighlightShare'; export function CopyHighlightsLink({ - link, + highlight, + origin, className, size = ButtonSize.Small, }: { - link?: string; + /** Links to this highlight on the page, or to the page without one. */ + highlight?: PostHighlight; + origin: Origin; 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; - } +}): ReactElement { + const [copied, copyLink] = useCopyLink(); + const logShare = useLogHighlightShare(origin, highlight); return ( @@ -34,10 +33,11 @@ export function CopyHighlightsLink({ className={className} icon={} onClick={(event: MouseEvent) => { - // The feed card is a link, and the page header sits above a tab bar. + // The feed card's rows are links. event.preventDefault(); event.stopPropagation(); - copyLink({ message: '✅ Copied link' }); + logShare(ShareProvider.CopyLink); + copyLink({ link: getHighlightsUrl(highlight?.id) }); }} size={size} type="button" diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 8e67c815750..83bdd703e2a 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -11,6 +11,9 @@ import { highlightsPageQueryOptions, postHighlightsFeedQueryOptions, } from '../../graphql/highlights'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureHappeningNowShare } from '../../lib/featureManagement'; +import { Origin } from '../../lib/log'; import { Tab, TabContainer } from '../tabs/TabContainer'; import { CopyHighlightsLink } from './CopyHighlightsLink'; import { DigestCTA } from './DigestCTA'; @@ -158,6 +161,9 @@ export const HighlightsPage = (): ReactElement => { const channel = getSingleQueryParam(router.query.channel); const expandedId = getSingleQueryParam(router.query.highlight); const isAllTab = router.pathname === ALL_HIGHLIGHTS_URL; + const { value: canShare } = useConditionalFeature({ + feature: featureHappeningNowShare, + }); const { data, isFetching } = useQuery(highlightsPageQueryOptions()); const channels = data?.channelConfigurations ?? []; @@ -174,11 +180,16 @@ export const HighlightsPage = (): ReactElement => { return (
-
-

+
+

Happening Now

- + {canShare && ( + + )}
Date: Thu, 10 Sep 2026 15:36:25 +0300 Subject: [PATCH 07/11] fix(snapshot): remove the review page, shutter sound and preview-host forcing #6556 took these out of the snapshot foundation and they came back with this branch: - useSharePlacement and isPreviewHost turned every share placement on for anyone on a `.preview.app.daily.dev` host, bypassing GrowthBook. The placements read `happening_now_share` with useConditionalFeature, and a preview is reviewed by opening the flag like any other. - shutterSound.ts and public/sounds/shutter.mp3 had no caller left once SnapshotButton came from main. - pages/dev/snapshot-happening-now was a review harness with stubbed auth, logging and flags; `/dev/*` pages do not ship. The flag comment now lists every control it gates. --- .../src/features/snapshot/shutterSound.ts | 23 -- .../snapshot/useSharePlacement.spec.tsx | 75 ----- .../features/snapshot/useSharePlacement.ts | 32 --- packages/shared/src/lib/constants.ts | 15 - packages/shared/src/lib/featureManagement.ts | 5 +- .../pages/dev/snapshot-happening-now.tsx | 257 ------------------ packages/webapp/public/sounds/shutter.mp3 | Bin 45824 -> 0 bytes 7 files changed, 3 insertions(+), 404 deletions(-) delete mode 100644 packages/shared/src/features/snapshot/shutterSound.ts delete mode 100644 packages/shared/src/features/snapshot/useSharePlacement.spec.tsx delete mode 100644 packages/shared/src/features/snapshot/useSharePlacement.ts delete mode 100644 packages/webapp/pages/dev/snapshot-happening-now.tsx delete mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/src/features/snapshot/shutterSound.ts b/packages/shared/src/features/snapshot/shutterSound.ts deleted file mode 100644 index ac00c91412d..00000000000 --- a/packages/shared/src/features/snapshot/shutterSound.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fromCDN } from '../../lib/links'; - -let shutter: HTMLAudioElement | null = null; - -/** - * One shared element rather than one per press: rewinding an existing clip is - * instant, while a fresh Audio has to fetch and decode before it plays. - */ -export function playShutterSound(): void { - if (typeof window === 'undefined') { - return; - } - - if (!shutter) { - shutter = new Audio(fromCDN('/sounds/shutter.mp3')); - shutter.volume = 0.45; - } - - shutter.currentTime = 0; - // Autoplay policy rejects until the page has been interacted with, and the - // capture must not fail because the sound did. - shutter.play().catch(() => {}); -} diff --git a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx deleted file mode 100644 index b8959641043..00000000000 --- a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { QueryClient } from '@tanstack/react-query'; -import { GrowthBook } from '@growthbook/growthbook-react'; -import { render, screen } from '@testing-library/react'; -import { TestBootProvider } from '../../../__tests__/helpers/boot'; -import { featureHappeningNowShare } from '../../lib/featureManagement'; -import { useSharePlacement } from './useSharePlacement'; - -const Probe = ({ shouldEvaluate }: { shouldEvaluate?: boolean }) => { - const enabled = useSharePlacement({ - feature: featureHappeningNowShare, - shouldEvaluate, - }); - - return {enabled ? 'on' : 'off'}; -}; - -const renderProbe = (gb?: GrowthBook, shouldEvaluate?: boolean) => - render( - - - , - ); - -const setHostname = (hostname: string) => { - Object.defineProperty(window, 'location', { - configurable: true, - value: { ...window.location, hostname }, - }); -}; - -const flagOn = () => { - const gb = new GrowthBook(); - gb.setFeatures({ - [featureHappeningNowShare.id]: { defaultValue: true }, - }); - - return gb; -}; - -describe('useSharePlacement', () => { - afterEach(() => setHostname('localhost')); - - it('follows the flag on the production host', () => { - setHostname('app.daily.dev'); - - renderProbe(flagOn()); - - expect(screen.getByText('on')).toBeInTheDocument(); - }); - - it('stays off on the production host when the flag is off', () => { - setHostname('app.daily.dev'); - - renderProbe(); - - expect(screen.getByText('off')).toBeInTheDocument(); - }); - - it('opens itself on a branch preview, where no flag can be reached', () => { - setHostname('my-branch.preview.app.daily.dev'); - - renderProbe(); - - expect(screen.getByText('on')).toBeInTheDocument(); - }); - - it('respects a surface that opted out, even on a preview', () => { - setHostname('my-branch.preview.app.daily.dev'); - - renderProbe(undefined, false); - - expect(screen.getByText('off')).toBeInTheDocument(); - }); -}); diff --git a/packages/shared/src/features/snapshot/useSharePlacement.ts b/packages/shared/src/features/snapshot/useSharePlacement.ts deleted file mode 100644 index 2c822d648a6..00000000000 --- a/packages/shared/src/features/snapshot/useSharePlacement.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { Feature } from '../../lib/featureManagement'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { isPreviewHost } from '../../lib/constants'; - -/** - * A share placement is on when its flag says so — or unconditionally on a - * branch preview deployment, which is the only way to review one: previews run - * as production against the production API, so there is no dev mode and no - * GrowthBook tooling to open the flag from the browser. - * - * The flag default stays false, so merging changes nothing for anyone on - * app.daily.dev; the rollout is still a GrowthBook decision. - */ -export function useSharePlacement({ - feature, - shouldEvaluate, -}: { - feature: Feature; - shouldEvaluate?: boolean; -}): boolean { - const { value } = useConditionalFeature({ feature, shouldEvaluate }); - // After mount, not during render: the server cannot know the host the page - // will be served from, and disagreeing with it would break hydration. - const [isPreview, setIsPreview] = useState(false); - - useEffect(() => { - setIsPreview(isPreviewHost()); - }, []); - - return value || (isPreview && shouldEvaluate !== false); -} diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 4663aa220b3..8e962b8f031 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -49,21 +49,6 @@ export const isTesting = process.env.NODE_ENV === 'test' || (!isDevelopment && !isProduction); export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true'; -/** - * Branch preview deployments, e.g. my-branch.preview.app.daily.dev. They run - * NODE_ENV=production against the production API, so neither `isDevelopment` - * nor GrowthBook's dev tools are available to open a flag for review — the - * host is the only thing that distinguishes them from app.daily.dev. - * - * Only this domain: the same deployment is also served from vercel.app, but - * the API rejects that origin on CORS, so the app never boots there. - */ -export const PREVIEW_HOST_SUFFIX = '.preview.app.daily.dev'; - -export const isPreviewHost = (): boolean => - typeof window !== 'undefined' && - window.location.hostname.endsWith(PREVIEW_HOST_SUFFIX); - export const isBrave = (): boolean => { if (typeof window === 'undefined' || !window.Promise) { return false; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 351977b2e8f..e22e5d4da02 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -30,8 +30,9 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); -// Every share affordance on Happening Now: snapshot on an expanded highlight, -// the selection bar inside its TLDR, and the copy-link controls. +// Every share control on Happening Now: copy link and snapshot on an expanded +// highlight, the selection bar over its TLDR, the page header's copy link, and +// the copy links on the feed's Happening Now card. export const featureHappeningNowShare = new Feature( 'happening_now_share', false, diff --git a/packages/webapp/pages/dev/snapshot-happening-now.tsx b/packages/webapp/pages/dev/snapshot-happening-now.tsx deleted file mode 100644 index 90d83da5e86..00000000000 --- a/packages/webapp/pages/dev/snapshot-happening-now.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { useEffect, useState } from 'react'; -import { NextSeo } from 'next-seo'; -import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; -import { - FeaturesReadyContext, - GrowthBookContext, -} from '@dailydotdev/shared/src/components/GrowthBookProvider'; -import { HighlightItem } from '@dailydotdev/shared/src/components/highlights/HighlightItem'; -import { HighlightSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotCard'; -import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; -import { featureHappeningNowShare } from '@dailydotdev/shared/src/lib/featureManagement'; -import type { PostHighlightFeed } from '@dailydotdev/shared/src/graphql/highlights'; -import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import type { LogContextData } from '@dailydotdev/shared/src/hooks/log/useLogContextData'; - -/** - * /dev/snapshot-happening-now — the expanded-highlight placement rendered by - * the production HighlightItem with `snapshot_highlight_expanded` forced on, - * so what is reviewed here is what ships. Blocked on the canonical production - * hosts and carries `noindex`/`nofollow`. - */ - -const hoursAgo = (hours: number): string => - new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); - -const HIGHLIGHTS: PostHighlightFeed[] = [ - { - id: 'dev-highlight-openai', - channel: 'headlines', - headline: 'OpenAI ships a cheaper model tier', - highlightedAt: hoursAgo(2), - post: { - id: 'dev-post-openai', - type: 'article', - commentsPermalink: 'https://app.daily.dev/posts/dev-post-openai', - summary: - 'Priced at a third of the previous tier with the same context window. The cut lands first on the API, with the assistant products following next quarter.', - }, - }, - { - id: 'dev-highlight-react', - channel: 'webdev', - headline: 'React 20 drops the legacy render path', - highlightedAt: hoursAgo(4), - post: { - id: 'dev-post-react', - type: 'article', - commentsPermalink: 'https://app.daily.dev/posts/dev-post-react', - summary: - 'The codemod covers most applications; class components with legacy context are the exception and will need a manual pass.', - }, - }, - { - id: 'dev-highlight-postgres', - channel: 'databases', - headline: - 'Postgres 19 lands asynchronous I/O by default across every supported platform', - highlightedAt: hoursAgo(6), - post: { - id: 'dev-post-postgres', - type: 'article', - commentsPermalink: 'https://app.daily.dev/posts/dev-post-postgres', - summary: - 'Early benchmarks show double-digit gains on write-heavy workloads, with the largest wins on NVMe and the smallest on network storage.', - }, - }, -]; - -const CARD_PREVIEW_SIZE = 300; - -const useIsAllowedHost = () => { - const [allowed, setAllowed] = useState(true); - - useEffect(() => { - const { hostname } = window.location; - setAllowed(hostname !== 'app.daily.dev' && hostname !== 'www.daily.dev'); - }, []); - - return allowed; -}; - -const LogContext = getLogContextStatic(); - -/** - * `/dev/*` short-circuits to a QueryClient-only tree in _app — no boot, no - * auth — which is what makes this page load without the API. HighlightItem - * reaches for both, so the review harness stands in: signed out, logging - * swallowed, and the flag forced rather than fetched. - */ -const AUTH_STUB = { - isLoggedIn: false, - isAuthReady: true, - tokenRefreshed: true, - shouldShowLogin: false, - squads: [], - showLogin: () => {}, - closeLogin: () => {}, - logout: async () => {}, - updateUser: async () => {}, - getRedirectUri: () => '', -} as unknown as AuthContextData; - -const LOG_STUB = { - logEvent: () => {}, - logEventStart: () => {}, - logEventEnd: () => {}, -} as unknown as LogContextData; - -const FORCED: Record = { - [featureHappeningNowShare.id]: true, -}; - -/* GrowthBookContext is re-exported for harnesses exactly like this one, so the - flag is pinned here rather than fetched. */ -const GB_STUB = { - getFeatureValue: (id: string, fallback: unknown) => FORCED[id] ?? fallback, -} as never; - -const DevProviders = ({ children }: { children: ReactNode }) => ( - - - - - (FORCED[feature.id] ?? feature.defaultValue) as never, - }} - > - {children} - - - - -); - -const Section = ({ - title, - caption, - children, -}: { - title: string; - caption: string; - children: ReactNode; -}) => ( -
-
-

{title}

-

{caption}

-
- {children} -
-); - -const Feed = ({ expandedId }: { expandedId?: string }) => ( -
- {HIGHLIGHTS.map((highlight) => ( - - ))} -
-); - -const SnapshotHappeningNowDevPage = (): ReactElement => { - const allowed = useIsAllowedHost(); - - if (!allowed) { - return ( -
-

- The snapshot review page is not available on production. -

-
- ); - } - - return ( - <> - - - -
-
-
-

- Snapshot on Happening now -

-

- Every highlight is a self-contained claim with sources behind - it, and today none of them can be lifted out. The page also has - the shortest shelf life in the product, which is why the image - beats the link: a URL sends someone to a page that has already - moved on. -

-

- These are the production rows with{' '} - snapshot_highlight_expanded forced on, not a copy - of them. On /highlights the flag defaults to off - and opens on branch previews. -

-
- -
-
- - -
-
- -
-
- {HIGHLIGHTS.map((highlight) => ( -
-
- {highlight.post.summary?.length} characters -
-
- {/* zoom, not transform: a transformed card leaves its - full height behind in the layout, and these grow. - It sits inside the width rather than on it, since - zoom scales the element's own box too. */} -
- -
-
-
- ))} -
-
-
-
-
- - ); -}; - -SnapshotHappeningNowDevPage.getLayout = (page: ReactNode): ReactNode => page; - -export default SnapshotHappeningNowDevPage; diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3 deleted file mode 100644 index f49b95f152c6d13f7a411f01abb94bab8b734be3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45824 zcmeI&Yfw{X8o=?B1Ofs=i#RjWV=XJbF^m-ov%JCpQbWN_v@^UOKF z_dn;;@%_k=Mtt~j2;Sutr2ea`{^dGworAr$d8+@rSpEBnyT2DZ?jYOy6ZVee!_jeK zwtB#coUMz07*7y7|MqMB#YRF1Mi&30^fd98)Rn@P8iFEPUmIhbK;6k!FQ8CujNV-M zkDpm%X&HTGyqnJ5C<(*BGE*4FpyZ562ufG!d8_aRIpc1zuG|%wlM7;=8=0&X#jpma zI7y#=^=?vr=pZ@#T3#}|er8+A{Q6l=#nAN4Sx!<(>h5oX3jW?luGu#DU2ku1-(r=e zOMj~&$t*RR-Wsss2`yc6EP)*md#p6-ZW}{M%9k+p?j6^qpBSg<*5qdp`WcLLy*?#l z=AGT>-%fVwQC?5#tbb~-y?U_Ic>Z>_iJZ?gyTmY0NFzI^+$y$Sp$qP1F0&=&2?CkG zD(BAFuhVm>0;`kl<$ci;T#xc*&)iX|wSK|Ct2xV;Pq{%(w8qWp|6D~^wd8%jNvg<| zh*wkc;;G?Z1|{k!b8MvDw0ufQFrE2~V&MJrl9;U5t&{?DN~foX;cJL!1CgxG;)TP$z5~(#u))g{>Jy& zyv(F|#)+vn{x736a$fwH{M3Wh^MEc&t6xRXZT2#on-~oQb z*U7ir2VVR*5<0QwNV7;3s(y?EMMXDLL?YR5GGS#uxtOf^onQ5}qajTwDiobN>l?Q* z@_bpOmzS4+Xry$1pu)E%QravOM!HuynZ%Yp+iH9!FwAc0@(p^C9rx|&?E^XzgNS5m zJC$&7HaSsYd@=6u9_ADGDyqbMi)F*3665shy5wTc!i#pE51G2kDJ4PSirPaOEqq9J zZa(Y94rce|K2l{QBIgwIGBrNA{jWXYKMD#JYYx4Mm!&>7+9Pw$;dMKfH~4*bW-Y5H zsMRUYQ{j9qsQi0nd5vs?pY?KT)A{bus`ja?REB7yc-DcSd}LK)+j#FzO>^$WHbGbZkk*~o5hrGHL z*NW21J0C@q3T<^=STW9&TDOiV)}rvvO|OO@=~K2PwOuWF>&-fp%2FNw>P29Y^uV`9 zKC5q14$FEY?56(wtM@(5x8@a=3Ddobx0jFYZ%y-Y zlw9@-=$nxqdQmO(|2X~Pk$rQD@c^aNY^5Z_ylz1yL32`m*6?70>RrC;Nt0C~S!*@< zt+(D&@9jm4a@%>gO&xf>w=!3(sO>9^5eFsYvaMsqDG&YarrVAbgpIu~znk6H!>eo? zp6@+(wEjbKO4HRjZyI8oo~Jve*pBFJ(XpneUuhNU_nP|M#*?Vufc^PN-4k<#$9@v$ z_h&6hOw9T?jrV!u#JI12A{ir|55BM~uZ8U4J!o!TS4Y(A)9l$-mo=t^R+fo(3&`27 z-71e8T^8iE;6l#u`(K57=gYqM@XB?xcX6?Is)3(Bm%xYQHklD7Qv?yGZcLvb7OFe@ z-{kNHf_K}W>%6q!X(2f+I7H2Q1V~P^&PxmK7LwC~L)5HCfaEmmytLqMAvrBLM9q2x zNKUiPOAGE6lGB1i)T~E<b<6^g9A;2hhZ2C~P(pAWvj8NA z85Gu`1Ryz-5M0MB0Lftng>@(aNDd_g*D(t~a+pD39ZCQ`mqQ7`b-V)blEdo>+mHf~ z9FhnI@Crb3cs*eoQUH=e62Smo0Z0z7Cu~CsKypYT7{Dt4$>H^cZAbw~4oL(9cm*Ii zyq>TPDFDeKiC_S)03?Uk6Sg4*AUPxv4B!=jH^cZAbw~4oL(9cm*Ii Zyq>TPDFDeKiC_S)03?Uk6Sg4*{tKU|N9h0n From 219b3de213888a37d2db0eb00d411ac88c709d87 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:25 +0300 Subject: [PATCH 08/11] chore(storybook): drop the Happening Now surface mockup now that it ships The surface story drew a fake Happening Now page to argue for the expanded-row placement. The placement is real now, so the live page is the reference, the same way #6556 retired the post page mockup. The overview row points at this PR. --- .../surfaces/HappeningNow.stories.tsx | 141 ------------------ .../snapshot/surfaces/Overview.stories.tsx | 4 +- 2 files changed, 2 insertions(+), 143 deletions(-) delete mode 100644 packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx deleted file mode 100644 index 1bb42e7ef4b..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { ButtonVariant } from '@dailydotdev/shared/src/components/buttons/Button'; -import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; -import { IconSize } from '@dailydotdev/shared/src/components/Icon'; -import type { DeviceName } from '../surfaceChrome'; -import { - Category, - Control, - Device, - Rail, - SurfacePage, - Variant, -} from '../surfaceChrome'; - -const TABS = ['Major headlines', 'All highlights', 'AI', 'Web']; - -const HIGHLIGHTS = [ - { - headline: 'OpenAI ships a cheaper model tier', - time: '2h ago', - tldr: 'Priced at a third of the previous tier with the same context window. Existing keys work unchanged, and the older tier stays available until March.', - }, - { headline: 'React 20 drops the legacy render path', time: '4h ago' }, - { headline: 'Postgres 19 lands async I/O by default', time: '6h ago' }, - { headline: 'Cloudflare open-sources its edge router', time: '9h ago' }, -]; - -const HappeningScreen = ({ device }: { device: DeviceName }) => ( - -
-
- {/* feed-highlights-title-gradient in production. */} -

- Happening Now -

- -
- -
- {TABS.map((tab, index) => ( - - {tab} - - ))} -
- - {HIGHLIGHTS.map((item, index) => { - const open = index === 0; - - return ( -
-
-
- - {item.headline} - - - {item.time} - -
- -
- - {open && item.tldr && ( -
-

{item.tldr}

-
- - Read more - - -
-
- )} -
- ); - })} -
-
-); - -const AllDevices = () => ( - - - - - -); - -const HappeningNow = () => ( - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Happening now', - component: HappeningNow, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx index f6ed7e190c9..424b66b466d 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx @@ -71,8 +71,8 @@ const PAGES: React.ReactNode[][] = [ ], [ 'Happening now', - '#6355', - 'Page, topic and highlight level — and what a page-level snapshot actually looks like at thumbnail size', + '#6570', + 'Shipped: the live Happening Now page is the reference, so it has no mockup here', ], [ 'Briefing', From 875f4ee987a076f51d22257f93c4fdecc2cd49e4 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:25 +0300 Subject: [PATCH 09/11] feat(highlights): ship the Happening Now share controls without a flag Product decided the sharing placements go out to everyone, so happening_now_share is gone along with every read of it. The page header's copy link, the feed card's copy links, and an expanded highlight's copy link, snapshot and quote bar now always render. The feed card's copy link carries ml-auto, so the options menu stays pinned to the right with no conditional class, and the page h1 keeps its intrinsic width so the wordmark gradient does not stretch. The flag-off test is dropped. The card and row specs render inside TestBootProvider, since the controls need the query client and log context wherever the card or an expanded row mounts. --- .../cards/highlight/HighlightCards.spec.tsx | 38 ++++++++--------- .../src/components/cards/highlight/common.tsx | 42 +++++++------------ .../highlights/HighlightItem.spec.tsx | 38 +++++++---------- .../components/highlights/HighlightItem.tsx | 18 +++----- .../components/highlights/HighlightsPage.tsx | 12 +----- .../snapshot/HighlightShareActions.tsx | 3 +- packages/shared/src/lib/featureManagement.ts | 7 ---- 7 files changed, 55 insertions(+), 103 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index c4ece554ac5..7c44fdc2ebf 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -1,10 +1,9 @@ +import type { ReactElement } from 'react'; import React from 'react'; import { QueryClient } from '@tanstack/react-query'; -import { GrowthBook } from '@growthbook/growthbook-react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; -import { featureHappeningNowShare } from '../../../lib/featureManagement'; import { LogEvent, Origin, TargetType } from '../../../lib/log'; import { ShareProvider } from '../../../lib/share'; import { HighlightGrid } from './HighlightGrid'; @@ -37,9 +36,16 @@ const highlights = [ }, ]; +const renderCard = (card: ReactElement, logEvent = jest.fn()) => + render( + + {card} + , + ); + 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(); @@ -61,7 +67,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(); @@ -72,7 +78,7 @@ describe('Highlight cards', () => { const onHighlightClick = jest.fn(); const onReadAllClick = jest.fn(); - render( + renderCard( { }); }); - const renderShareable = ( - logEvent: jest.Mock, - onHighlightClick?: jest.Mock, - ) => { - const gb = new GrowthBook(); - gb.setFeatures({ [featureHappeningNowShare.id]: { defaultValue: true } }); - - render( - - - , + const renderShareable = (logEvent: jest.Mock, onHighlightClick?: jest.Mock) => + renderCard( + , + logEvent, ); - }; it('copies a highlight from its row without opening it', async () => { const logEvent = jest.fn(); diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index e398165a358..d4c7c743981 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -7,8 +7,6 @@ import { RelativeTime } from '../../utilities/RelativeTime'; import Link from '../../utilities/Link'; import { ButtonSize } from '../../buttons/common'; import { CopyHighlightsLink } from '../../highlights/CopyHighlightsLink'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; -import { featureHappeningNowShare } from '../../../lib/featureManagement'; import { Origin } from '../../../lib/log'; import { HighlightCardOptions } from './HighlightCardOptions'; @@ -65,12 +63,10 @@ const HighlightRow = ({ highlight, index, onHighlightClick, - canShare, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; - canShare: boolean; }): ReactElement => { return ( @@ -88,14 +84,12 @@ const HighlightRow = ({ maxHoursAgo={72} className="text-text-tertiary typo-footnote" /> - {canShare && ( - - )} + @@ -118,9 +112,6 @@ export const HighlightCardContent = ({ : 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto px-2.5 pb-1 pt-0'; const footerClassName = variant === 'list' ? 'pt-1.5' : 'px-1 pb-1'; const firstHighlight = highlights[0]; - const { value: canShare } = useConditionalFeature({ - feature: featureHappeningNowShare, - }); return ( <> @@ -133,17 +124,15 @@ export const HighlightCardContent = ({ > Happening Now

- {canShare && ( - - )} - + +
{highlights.map((highlight, index) => ( @@ -152,7 +141,6 @@ export const HighlightCardContent = ({ highlight={highlight} index={index} onHighlightClick={onHighlightClick} - canShare={canShare} /> ))}
diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index cc5f3b4b874..c35086f841e 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,10 +1,9 @@ +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { QueryClient } from '@tanstack/react-query'; -import { GrowthBook } from '@growthbook/growthbook-react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; import type { PostHighlightFeed } from '../../graphql/highlights'; -import { featureHappeningNowShare } from '../../lib/featureManagement'; import { LogEvent, Origin, TargetType } from '../../lib/log'; import { ShareProvider } from '../../lib/share'; import { HighlightItem } from './HighlightItem'; @@ -36,22 +35,23 @@ beforeEach(() => { scrollIntoView.mockClear(); }); -const renderWithSnapshot = (defaultExpanded = false, logEvent = jest.fn()) => { - const gb = new GrowthBook(); - gb.setFeatures({ - [featureHappeningNowShare.id]: { defaultValue: true }, - }); +const renderItem = (defaultExpanded = false, logEvent = jest.fn()) => { + const client = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + + ); return render( - - - , + , + { wrapper }, ); }; describe('HighlightItem', () => { it('should expand when the route-driven default changes after mount', () => { - const { rerender } = render(); + const { rerender } = renderItem(); expect(screen.queryByText(summary)).not.toBeInTheDocument(); @@ -65,16 +65,8 @@ describe('HighlightItem', () => { expect(scrollIntoView).toHaveBeenCalled(); }); - it('keeps an expanded highlight free of share controls while the flag is off', () => { - render(); - - expect( - screen.queryByRole('button', { name: /snapshot/i }), - ).not.toBeInTheDocument(); - }); - - it('offers nothing on a collapsed row even with the flag on', () => { - renderWithSnapshot(); + it('offers nothing on a collapsed row', () => { + renderItem(); expect( screen.queryByRole('button', { name: /snapshot/i }), @@ -82,7 +74,7 @@ describe('HighlightItem', () => { }); it('offers snapshot and copy link beside Read more when expanded', () => { - renderWithSnapshot(true); + renderItem(true); expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); expect(screen.getByRole('button', { name: /copy link/i })).toBeVisible(); @@ -94,7 +86,7 @@ describe('HighlightItem', () => { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, }); const logEvent = jest.fn(); - renderWithSnapshot(true, logEvent); + renderItem(true, logEvent); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /copy link/i })); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 713cef0349b..e5f10c1b4e1 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -9,8 +9,6 @@ import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureHappeningNowShare } from '../../lib/featureManagement'; interface HighlightItemProps { highlight: PostHighlightFeed; @@ -24,10 +22,6 @@ export const HighlightItem = ({ const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); const tldrRef = useRef(null); - const { value: canShare } = useConditionalFeature({ - feature: featureHappeningNowShare, - shouldEvaluate: expanded, - }); useEffect(() => { if (defaultExpanded) { @@ -100,13 +94,11 @@ export const HighlightItem = ({ Read more - {canShare && ( - - )} +
)} diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 83bdd703e2a..d4d1b5ca4b3 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -11,8 +11,6 @@ import { highlightsPageQueryOptions, postHighlightsFeedQueryOptions, } from '../../graphql/highlights'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureHappeningNowShare } from '../../lib/featureManagement'; import { Origin } from '../../lib/log'; import { Tab, TabContainer } from '../tabs/TabContainer'; import { CopyHighlightsLink } from './CopyHighlightsLink'; @@ -161,9 +159,6 @@ export const HighlightsPage = (): ReactElement => { const channel = getSingleQueryParam(router.query.channel); const expandedId = getSingleQueryParam(router.query.highlight); const isAllTab = router.pathname === ALL_HIGHLIGHTS_URL; - const { value: canShare } = useConditionalFeature({ - feature: featureHappeningNowShare, - }); const { data, isFetching } = useQuery(highlightsPageQueryOptions()); const channels = data?.channelConfigurations ?? []; @@ -184,12 +179,7 @@ export const HighlightsPage = (): ReactElement => {

Happening Now

- {canShare && ( - - )} + Date: Thu, 10 Sep 2026 17:27:25 +0300 Subject: [PATCH 10/11] fix(highlights): copy absolute, tracked links from Happening Now The page header link on /highlights, the feed card's header link and its per-row links all built their URL from `webappUrl`, which is a bare `/` on the webapp, so the clipboard got `/highlights` or `/highlights?highlight=`: a path that means nothing once pasted anywhere else. A row now copies the highlight's post permalink, the same link the expanded row on /highlights already copied and the one both of them log as `share post`. The deep link was the weaker target: /highlights is statically generated, so a crawler gets the generic page title, the default image and `og:url` pointing at /highlights whatever the query says, and the `highlight` param only expands a row that is still in the loaded list. The post page previews with the headline, the TLDR and the post's own image. The page links resolve `/highlights` against the current origin the way `agentShareLink` does, since the share pipeline runs `new URL(link)` and threw on the bare path. Both go through `useCopyLink`'s `shorten` path with a campaign, as PostMenuOptions does: the long link is written inside the click, then the short link carrying `userid` and `cid` replaces it. The page gets its own `share_highlights` campaign, added to the join page's campaign map, which has to list every key. --- .../cards/highlight/HighlightCards.spec.tsx | 106 ++++++++++++++++-- .../highlights/CopyHighlightsLink.tsx | 23 +++- packages/shared/src/lib/links.ts | 10 ++ packages/shared/src/lib/referral.ts | 1 + packages/webapp/pages/join/index.tsx | 1 + 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx index 7c44fdc2ebf..5ed58fae2cd 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx @@ -4,8 +4,12 @@ import { QueryClient } from '@tanstack/react-query'; import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import loggedUser from '../../../../__tests__/fixture/loggedUser'; +import { gqlClient } from '../../../graphql/common'; import { LogEvent, Origin, TargetType } from '../../../lib/log'; +import { ReferralCampaignKey } from '../../../lib/referral'; import { ShareProvider } from '../../../lib/share'; +import type { LoggedUser } from '../../../lib/user'; import { HighlightGrid } from './HighlightGrid'; import { HighlightList } from './HighlightList'; @@ -21,7 +25,7 @@ const highlights = [ highlightedAt: '2026-04-05T09:00:00.000Z', post: { id: 'post-1', - commentsPermalink: '/posts/post-1', + commentsPermalink: 'https://app.daily.dev/posts/post-1', }, }, { @@ -31,14 +35,22 @@ const highlights = [ highlightedAt: '2026-04-05T08:00:00.000Z', post: { id: 'post-2', - commentsPermalink: '/posts/post-2', + commentsPermalink: 'https://app.daily.dev/posts/post-2', }, }, ]; -const renderCard = (card: ReactElement, logEvent = jest.fn()) => +const renderCard = ( + card: ReactElement, + logEvent = jest.fn(), + user?: LoggedUser, +) => render( - + {card} , ); @@ -97,10 +109,14 @@ describe('Highlight cards', () => { }); describe('Highlight card share controls', () => { + const writeText = jest.fn().mockResolvedValue(undefined); + beforeAll(() => { - Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, - }); + Object.assign(navigator, { clipboard: { writeText } }); + }); + + beforeEach(() => { + writeText.mockClear(); }); const renderShareable = (logEvent: jest.Mock, onHighlightClick?: jest.Mock) => @@ -124,6 +140,11 @@ describe('Highlight card share controls', () => { }); expect(onHighlightClick).not.toHaveBeenCalled(); + // The post, not a deep link into the page: one highlight shares one link + // wherever it is copied from. + expect(writeText).toHaveBeenCalledWith( + 'https://app.daily.dev/posts/post-1', + ); const [[event]] = logEvent.mock.calls; expect(event).toMatchObject({ event_name: LogEvent.SharePost, @@ -137,7 +158,7 @@ describe('Highlight card share controls', () => { }); }); - it('logs the header link as a share of the page', async () => { + it('copies the page as an absolute link from the header', async () => { const logEvent = jest.fn(); renderShareable(logEvent); const [header] = screen.getAllByRole('button', { name: 'Copy link' }); @@ -146,6 +167,8 @@ describe('Highlight card share controls', () => { fireEvent.click(header); }); + // `webappUrl` is a bare `/` on the webapp, which pasted as a path. + expect(writeText).toHaveBeenCalledWith('http://localhost/highlights'); const [[event]] = logEvent.mock.calls; expect(event.event_name).toBe(LogEvent.ShareHighlights); expect(event.target_id).toBeUndefined(); @@ -154,4 +177,71 @@ describe('Highlight card share controls', () => { origin: Origin.HighlightsCard, }); }); + + describe('once the short link resolves', () => { + const items: Record>[] = []; + + beforeEach(() => { + items.length = 0; + Object.assign(globalThis, { + ClipboardItem: class { + constructor(data: Record>) { + items.push(data); + } + }, + }); + Object.assign(navigator.clipboard, { + write: jest.fn().mockResolvedValue(undefined), + }); + // An unreachable shortener leaves the tracked long link in place. + jest.spyOn(gqlClient, 'request').mockRejectedValue(new Error('offline')); + }); + + afterEach(() => { + delete (globalThis as { ClipboardItem?: unknown }).ClipboardItem; + jest.mocked(gqlClient.request).mockRestore(); + }); + + const readSwappedLink = async (): Promise => { + const blob = await items[0]['text/plain']; + const text = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(blob); + }); + + return new URL(text); + }; + + it.each([ + [ + 'the page', + 0, + 'http://localhost/highlights', + ReferralCampaignKey.ShareHighlights, + ], + [ + 'a row', + 1, + 'https://app.daily.dev/posts/post-1', + ReferralCampaignKey.SharePost, + ], + ])('tracks %s link to the sharer', async (_, index, expected, cid) => { + renderCard(, jest.fn(), { + ...loggedUser, + id: 'sharer', + }); + + await act(async () => { + fireEvent.click( + screen.getAllByRole('button', { name: 'Copy link' })[index], + ); + }); + + const link = await readSwappedLink(); + expect(`${link.origin}${link.pathname}`).toBe(expected); + expect(link.searchParams.get('cid')).toBe(cid); + expect(link.searchParams.get('userid')).toBe('sharer'); + }); + }); }); diff --git a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx index 07f4bf6b2c6..9f84294f769 100644 --- a/packages/shared/src/components/highlights/CopyHighlightsLink.tsx +++ b/packages/shared/src/components/highlights/CopyHighlightsLink.tsx @@ -7,7 +7,8 @@ import { Tooltip } from '../tooltip/Tooltip'; import { useCopyLink } from '../../hooks/useCopy'; import type { PostHighlight } from '../../graphql/highlights'; import type { Origin } from '../../lib/log'; -import { getHighlightsUrl } from '../../lib/links'; +import { getHighlightsShareUrl } from '../../lib/links'; +import { ReferralCampaignKey } from '../../lib/referral'; import { ShareProvider } from '../../lib/share'; import { useLogHighlightShare } from '../../features/snapshot/useLogHighlightShare'; @@ -17,8 +18,8 @@ export function CopyHighlightsLink({ className, size = ButtonSize.Small, }: { - /** Links to this highlight on the page, or to the page without one. */ - highlight?: PostHighlight; + /** Links to this highlight's post, or to the page without one. */ + highlight?: Pick; origin: Origin; className?: string; size?: ButtonSize; @@ -37,7 +38,21 @@ export function CopyHighlightsLink({ event.preventDefault(); event.stopPropagation(); logShare(ShareProvider.CopyLink); - copyLink({ link: getHighlightsUrl(highlight?.id) }); + // `shorten`, not an awaited short URL: the write has to stay inside + // the task that handled the click or Safari refuses it. + copyLink( + highlight + ? { + link: highlight.post.commentsPermalink, + shorten: true, + cid: ReferralCampaignKey.SharePost, + } + : { + link: getHighlightsShareUrl(), + shorten: true, + cid: ReferralCampaignKey.ShareHighlights, + }, + ); }} size={size} type="button" diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index 30dca746b1d..61f5cb39e4c 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -194,3 +194,13 @@ export const getHighlightsUrl = (highlightId?: string): string => { return highlightId ? `${base}?highlight=${highlightId}` : base; }; + +/** The Happening Now page as a link that still works once it leaves the tab. */ +export const getHighlightsShareUrl = (): string => { + const path = getHighlightsUrl(); + // `webappUrl` is a bare path on the webapp, and the share pipeline runs + // `new URL(link)` on whatever it is handed. + const origin = globalThis?.location?.origin; + + return origin ? new URL(path, origin).toString() : path; +}; diff --git a/packages/shared/src/lib/referral.ts b/packages/shared/src/lib/referral.ts index 49ba9fbf329..447b901c0ed 100644 --- a/packages/shared/src/lib/referral.ts +++ b/packages/shared/src/lib/referral.ts @@ -8,4 +8,5 @@ export enum ReferralCampaignKey { ShareTag = 'share_tag', ShareAgent = 'share_agent', ShareSlack = 'share_slack', + ShareHighlights = 'share_highlights', } diff --git a/packages/webapp/pages/join/index.tsx b/packages/webapp/pages/join/index.tsx index d139df6df88..1710eb7dd1e 100644 --- a/packages/webapp/pages/join/index.tsx +++ b/packages/webapp/pages/join/index.tsx @@ -29,6 +29,7 @@ const componentsMap: ReferralRecord> = { [ReferralCampaignKey.ShareTag]: Referral, [ReferralCampaignKey.ShareAgent]: Referral, [ReferralCampaignKey.ShareSlack]: Referral, + [ReferralCampaignKey.ShareHighlights]: Referral, }; const referralCampaignValues = new Set( From 1ff6c5bcdddac8a32e9aaa0bb112b72796e875e1 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:27:44 +0300 Subject: [PATCH 11/11] fix(snapshot): credit the source on the Happening Now share cards The expanded-highlight card carried the "Happening now" eyebrow but no credit, and the quote card over the TLDR carried neither, where the briefing and post-page cards name the source under a rule. An image passed around without a byline reads as daily.dev's own words. The highlights feed fragment now asks for the post's `source` and `domain` (and the shared post's, which is where a share's TLDR comes from), and both cards take `snapshotSource` of the same post the TLDR is read from. `snapshotSource` accepts any `{ name, image }` source rather than the full `Source`, which is all it ever read. The quote bar passes the eyebrow through to its card so both images carry the same label. The expanded row's copy link is now the same control as the feed card's rows, so one highlight is copied, tracked and logged the same way on every surface. --- .../highlights/HighlightItem.spec.tsx | 41 +++++++++++++-- .../components/highlights/HighlightItem.tsx | 14 ++--- .../snapshot/HighlightShareActions.tsx | 51 ++++++++----------- .../snapshot/SelectionSnapshotBar.tsx | 6 ++- .../src/features/snapshot/snapshotSource.ts | 3 +- packages/shared/src/graphql/highlights.ts | 17 +++++++ 6 files changed, 89 insertions(+), 43 deletions(-) diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index c35086f841e..fa18e5b8bcc 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -21,6 +21,7 @@ const highlight: PostHighlightFeed = { type: 'article', commentsPermalink: '/posts/post-1', summary, + source: { name: 'The Pragmatic Engineer', image: 'https://img/source' }, }, }; @@ -29,6 +30,9 @@ beforeAll(() => { configurable: true, value: scrollIntoView, }); + // jsdom has no layout, and the quote bar refuses a selection it cannot place. + Range.prototype.getBoundingClientRect = () => + ({ top: 400, bottom: 440, left: 100, width: 300 } as DOMRect); }); beforeEach(() => { @@ -81,10 +85,9 @@ describe('HighlightItem', () => { expect(screen.getByRole('link', { name: /read more/i })).toBeVisible(); }); - it('logs a copied link as a share of the highlighted post', async () => { - Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, - }); + it('copies and logs the highlighted post link', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); const logEvent = jest.fn(); renderItem(true, logEvent); @@ -92,6 +95,8 @@ describe('HighlightItem', () => { fireEvent.click(screen.getByRole('button', { name: /copy link/i })); }); + // The same link the feed card's row copies for this highlight. + expect(writeText).toHaveBeenCalledWith('/posts/post-1'); const [[event]] = logEvent.mock.calls; expect(event).toMatchObject({ event_name: LogEvent.SharePost, @@ -104,4 +109,32 @@ describe('HighlightItem', () => { highlight_id: 'highlight-1', }); }); + + it('labels and credits the TLDR snapshot', () => { + renderItem(true); + + // Focus arms the off-screen card the capture reads. + fireEvent.focus(screen.getByRole('button', { name: /snapshot/i })); + + expect(screen.getByText('Happening now')).toBeInTheDocument(); + expect(screen.getByText('The Pragmatic Engineer')).toBeInTheDocument(); + }); + + it('labels and credits a quote selected in the TLDR', () => { + renderItem(true); + const node = screen.getByText(summary).firstChild as Node; + const range = document.createRange(); + range.setStart(node, 0); + range.setEnd(node, node.textContent?.length ?? 0); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + + fireEvent.pointerUp(document); + + expect( + screen.getByRole('toolbar', { name: 'Share selected text' }), + ).toBeInTheDocument(); + expect(screen.getByText('Happening now')).toBeInTheDocument(); + expect(screen.getByText('The Pragmatic Engineer')).toBeInTheDocument(); + }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index e5f10c1b4e1..1b4127e8948 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -9,6 +9,7 @@ import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions'; +import { snapshotSource } from '../../features/snapshot/snapshotSource'; interface HighlightItemProps { highlight: PostHighlightFeed; @@ -35,12 +36,12 @@ export const HighlightItem = ({ } }, [defaultExpanded]); - const tldr = useMemo(() => { - const post = - highlight.post.type === PostType.Share && highlight.post.sharedPost - ? highlight.post.sharedPost - : highlight.post; + const post = + highlight.post.type === PostType.Share && highlight.post.sharedPost + ? highlight.post.sharedPost + : highlight.post; + const tldr = useMemo(() => { const summary = post.summary?.trim(); if (summary) { return summary; @@ -52,7 +53,7 @@ export const HighlightItem = ({ } return ''; - }, [highlight.post]); + }, [post]); return (
@@ -96,6 +97,7 @@ export const HighlightItem = ({ diff --git a/packages/shared/src/features/snapshot/HighlightShareActions.tsx b/packages/shared/src/features/snapshot/HighlightShareActions.tsx index 1516268d905..2fdd912caea 100644 --- a/packages/shared/src/features/snapshot/HighlightShareActions.tsx +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -1,16 +1,10 @@ import type { ReactElement, RefObject } from 'react'; import React, { useCallback, useRef } from 'react'; -import { Button } from '../../components/buttons/Button'; -import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; -import { LinkIcon } from '../../components/icons/Link'; +import { CopyHighlightsLink } from '../../components/highlights/CopyHighlightsLink'; import type { SnapshotResult } from '../../components/imageShare/SnapshotButton'; import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; -import { CopyStateIcon } from '../../components/share/CopyStateIcon'; -import { Tooltip } from '../../components/tooltip/Tooltip'; import type { PostHighlightFeed } from '../../graphql/highlights'; -import { useCopyPostLink } from '../../hooks/useCopyPostLink'; import { Origin } from '../../lib/log'; -import { ReferralCampaignKey } from '../../lib/referral'; import { ShareProvider } from '../../lib/share'; import colors from '../../styles/colors'; import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; @@ -27,6 +21,13 @@ import { useLogHighlightShare } from './useLogHighlightShare'; */ const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; +const HappeningNowEyebrow = (): ReactElement => ( + +); + /** * Copy link and Snapshot for an expanded highlight, plus the quote bar over * its TLDR. Mounts only once a row expands, so collapsed rows run none of it. @@ -35,14 +36,16 @@ export function HighlightShareActions({ highlight, tldr, tldrRef, + source, }: { highlight: PostHighlightFeed; tldr: string; tldrRef: RefObject; + /** Who wrote the TLDR, credited on both cards. */ + source?: { name: string; image?: string }; }): ReactElement { const cardRef = useRef(null); const { isArmed, armProps } = useArmedCard(); - const [copied, copyLink] = useCopyPostLink(); const logShare = useLogHighlightShare( Origin.HappeningNowHighlight, highlight, @@ -51,12 +54,6 @@ export function HighlightShareActions({ Origin.HappeningNowSelection, highlight, ); - const link = highlight.post.commentsPermalink; - - const onCopyLink = () => { - logShare(ShareProvider.CopyLink); - copyLink({ link, shorten: true, cid: ReferralCampaignKey.SharePost }); - }; const onSnapshot = useCallback( (result: SnapshotResult) => logShare(ShareProvider.Snapshot, result), @@ -65,16 +62,10 @@ export function HighlightShareActions({ return ( <> - -
)} } + link={highlight.post.commentsPermalink} onShare={logSelectionShare} seed={highlight.id} + source={source} /> ); diff --git a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx index deed6ea3f03..59877446d0f 100644 --- a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx +++ b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, RefObject } from 'react'; +import type { ReactElement, ReactNode, RefObject } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { @@ -60,6 +60,8 @@ export interface SelectionShareBarProps { /** Seeds the card's gradient and names the downloaded file. */ seed: string; source?: { name: string; image?: string }; + /** The surface's own label on the card's logo row. */ + label?: ReactNode; /** Called once per action, with how a snapshot ended, so the host logs it. */ onShare: (provider: ShareProvider, result?: SnapshotResult) => void; } @@ -69,6 +71,7 @@ export function SelectionShareBar({ link, seed, source, + label, onShare, }: SelectionShareBarProps): ReactElement | null { const barRef = useRef(null); @@ -164,6 +167,7 @@ export function SelectionShareBar({ , + post: Pick & { source?: Pick }, ): { name: string; image?: string } | undefined { const { source, domain } = post; diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 3de88fddf36..60c083699d7 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -2,6 +2,7 @@ import { gql } from 'graphql-request'; import { gqlClient } from './common'; import type { Connection } from './common'; import type { PostHighlightSignificance } from './types'; +import type { Source } from './sources'; import { ONE_MINUTE } from '../lib/time'; export interface PostHighlight { @@ -15,6 +16,8 @@ export interface PostHighlight { }; } +type HighlightFeedSource = Pick; + export interface PostHighlightFeed { id: string; channel: string; @@ -27,9 +30,13 @@ export interface PostHighlightFeed { commentsPermalink: string; summary?: string; contentHtml?: string; + domain?: string; + source?: HighlightFeedSource; sharedPost?: { summary?: string; contentHtml?: string; + domain?: string; + source?: HighlightFeedSource; }; }; } @@ -118,9 +125,19 @@ export const POST_HIGHLIGHT_FEED_FRAGMENT = gql` commentsPermalink summary contentHtml + domain + source { + name + image + } sharedPost { summary contentHtml + domain + source { + name + image + } } } }