diff --git a/packages/shared/src/components/highlights/DigestCTA.tsx b/packages/shared/src/components/highlights/DigestCTA.tsx index 17d6d09b86c..19b4f2b1962 100644 --- a/packages/shared/src/components/highlights/DigestCTA.tsx +++ b/packages/shared/src/components/highlights/DigestCTA.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; +import classNames from 'classnames'; import type { ChannelDigestConfiguration } from '../../graphql/highlights'; import type { Source } from '../../graphql/sources'; import { SourceType } from '../../graphql/sources'; @@ -9,6 +10,9 @@ import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollo import { useSourceActionsNotify } from '../../hooks/source/useSourceActionsNotify'; import SourceActionsNotify from '../sources/SourceActions/SourceActionsNotify'; import Link from '../utilities/Link'; +import { HighlightShareButton } from './HighlightShareButton'; +import { ButtonSize } from '../buttons/Button'; +import { ReferralCampaignKey } from '../../lib/referral'; const CTA_HEIGHT = 'h-10'; @@ -22,6 +26,12 @@ const DigestCTASkeleton = (): ReactElement => ( interface DigestCTAProps { digest: ChannelDigestConfiguration; displayName: string; + /** + * Absolute link to this channel's Happening Now tab. Only set when + * `share_happening_now` is on, so the control is gated on the prop and the + * flag-off DOM is untouched. + */ + shareLink?: string; } interface DigestCTAContentProps extends DigestCTAProps { @@ -31,6 +41,7 @@ interface DigestCTAContentProps extends DigestCTAProps { const DigestCTAContent = ({ digest, displayName, + shareLink, source, }: DigestCTAContentProps): ReactElement => { const { isAuthReady, isLoggedIn } = useAuthContext(); @@ -61,7 +72,9 @@ const DigestCTAContent = ({
- + {/* The share control eats horizontal room, so the copy only gets the + shrink + ellipsis treatment when it is actually rendered. */} + Get a {digest.frequency} digest of{' '} @@ -70,7 +83,19 @@ const DigestCTAContent = ({ {' '} news - + {shareLink && ( + + )} + { @@ -87,6 +112,7 @@ const DigestCTAContent = ({ export const DigestCTA = ({ digest, displayName, + shareLink, }: DigestCTAProps): ReactElement | null => { const source = digest.source ? { @@ -104,6 +130,7 @@ export const DigestCTA = ({ ); diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 5f1e186989a..095ff48f228 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,10 +1,28 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; import type { PostHighlightFeed } from '../../graphql/highlights'; import { HighlightItem } from './HighlightItem'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { useViewSize } from '../../hooks/useViewSize'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; const scrollIntoView = jest.fn(); const summary = 'A concise summary for the expanded highlight item.'; +const SHARE_LABEL = 'Share this highlight'; const highlight: PostHighlightFeed = { id: 'highlight-1', @@ -27,7 +45,8 @@ beforeAll(() => { }); beforeEach(() => { - scrollIntoView.mockClear(); + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); // default: laptop }); describe('HighlightItem', () => { @@ -46,3 +65,79 @@ describe('HighlightItem', () => { expect(scrollIntoView).toHaveBeenCalled(); }); }); + +let client: QueryClient; + +const renderWithShare = (showShare: boolean) => { + client = new QueryClient(); + return render( + + + , + ); +}; + +describe('HighlightItem share control', () => { + it('should not render a share control when the flag is off', () => { + renderWithShare(false); + + expect( + screen.getByRole('link', { name: /read more/i }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText(SHARE_LABEL)).not.toBeInTheDocument(); + }); + + it('should render exactly one share control per highlight when enabled', () => { + renderWithShare(true); + + expect(screen.getAllByLabelText(SHARE_LABEL)).toHaveLength(1); + expect( + screen.getByRole('link', { name: /read more/i }), + ).toBeInTheDocument(); + }); + + it('should copy the post link and show a toast on desktop', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + renderWithShare(true); + + await act(async () => { + fireEvent.click(screen.getByLabelText(SHARE_LABEL)); + }); + await act(async () => { + fireEvent.click(await screen.findByText('Copy link')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith('/posts/post-1'), + ); + await waitFor(() => + expect( + client.getQueryData(TOAST_NOTIF_KEY)?.message, + ).toEqual('✅ Copied link to clipboard'), + ); + }); + + it('should open the native share sheet on a single tap on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share, maxTouchPoints: 2 }); + + renderWithShare(true); + + await act(async () => { + fireEvent.click(screen.getByLabelText(SHARE_LABEL)); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: `${highlight.headline}\n/posts/post-1`, + }), + ); + }); +}); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 4256c618b38..4eb03587638 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -6,17 +6,23 @@ import { stripHtmlTags } from '../../lib/strings'; import { PostType } from '../../graphql/posts'; import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; +import { ButtonSize } from '../buttons/Button'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; +import { HighlightShareButton } from './HighlightShareButton'; +import { ReferralCampaignKey } from '../../lib/referral'; interface HighlightItemProps { highlight: PostHighlightFeed; defaultExpanded?: boolean; + /** Gated by `share_happening_now`; resolved once by `HighlightsPage`. */ + showShare?: boolean; } export const HighlightItem = ({ highlight, defaultExpanded = false, + showShare = false, }: HighlightItemProps): ReactElement => { const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); @@ -52,6 +58,14 @@ export const HighlightItem = ({ return ''; }, [highlight.post]); + const readMore = ( + + + Read more + + + ); + return (
)} diff --git a/packages/shared/src/components/highlights/HighlightShareButton.tsx b/packages/shared/src/components/highlights/HighlightShareButton.tsx new file mode 100644 index 00000000000..4b7c26231e0 --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightShareButton.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { ShareActions } from '../share/ShareActions'; +import type { ButtonSize } from '../buttons/Button'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; + +// Which of the three Happening Now nesting levels the control belongs to. Kept +// on the log payload so the levels can be compared without three log events. +export type HighlightShareLevel = 'page' | 'topic' | 'highlight'; + +export interface HighlightShareButtonProps { + link: string; + text: string; + label: string; + level: HighlightShareLevel; + targetId: string; + cid?: ReferralCampaignKey; + buttonSize?: ButtonSize; + className?: string; +} + +// Thin logging wrapper around the shared `ShareActions` primitive so all three +// Happening Now levels emit an identically shaped event. `LogEvent.ShareLog` is +// the generic share event: a highlight is not a full `Post` here (the feed query +// only returns id + permalink), so emitting `SharePost` would produce events +// missing every standard post property. +export const HighlightShareButton = ({ + link, + text, + label, + level, + targetId, + cid, + buttonSize, + className, +}: HighlightShareButtonProps): ReactElement => { + const { logEvent } = useLogContext(); + + const onShare = useCallback( + (provider: ShareProvider) => { + logEvent({ + event_name: LogEvent.ShareLog, + target_id: targetId, + extra: JSON.stringify({ + origin: Origin.HappeningNow, + provider, + level, + }), + }); + }, + [logEvent, targetId, level], + ); + + return ( + + ); +}; diff --git a/packages/shared/src/components/highlights/HighlightsPage.spec.tsx b/packages/shared/src/components/highlights/HighlightsPage.spec.tsx new file mode 100644 index 00000000000..7a3c4bc8830 --- /dev/null +++ b/packages/shared/src/components/highlights/HighlightsPage.spec.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import nock from 'nock'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { HighlightsPage } from './HighlightsPage'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import { + HIGHLIGHTS_PAGE_QUERY, + MAJOR_HEADLINES_MAX_FIRST, + POST_HIGHLIGHTS_FEED_QUERY, +} from '../../graphql/highlights'; +import { + featureSharingVisibility, + featureShareHappeningNow, +} from '../../lib/featureManagement'; + +const PAGE_SHARE_LABEL = 'Share Happening Now'; +const TOPIC_SHARE_LABEL = 'Share AI agents'; +const ITEM_SHARE_LABEL = 'Share this highlight'; + +const summary = 'A concise summary for the expanded highlight item.'; + +const highlightNode = (id: string) => ({ + id, + channel: 'agents', + headline: `Headline ${id}`, + highlightedAt: '2026-04-05T09:00:00.000Z', + significance: 'major', + post: { + id: `post-${id}`, + type: 'article', + commentsPermalink: `/posts/post-${id}`, + summary, + }, +}); + +const channelConfiguration = { + channel: 'agents', + displayName: 'AI agents', + digest: { + frequency: 'daily', + source: { + id: 'source-1', + name: 'AI agents', + image: 'https://daily.dev/image.jpg', + handle: 'ai-agents', + permalink: 'https://daily.dev/sources/ai-agents', + }, + }, +}; + +const mockPageQuery = () => + mockGraphQL({ + request: { + query: HIGHLIGHTS_PAGE_QUERY, + variables: { first: MAJOR_HEADLINES_MAX_FIRST }, + }, + result: { + data: { + majorHeadlines: { + pageInfo: { endCursor: null, hasNextPage: false }, + edges: [{ node: highlightNode('h1') }], + }, + channelConfigurations: [channelConfiguration], + }, + }, + }); + +const mockChannelQuery = () => + mockGraphQL({ + request: { + query: POST_HIGHLIGHTS_FEED_QUERY, + variables: { channel: 'agents' }, + }, + result: { + data: { postHighlights: [highlightNode('h1'), highlightNode('h2')] }, + }, + }); + +beforeEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: jest.fn(), + }); +}); + +const getGrowthBook = (enabled: boolean): GrowthBook => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureSharingVisibility.id]: { defaultValue: enabled }, + [featureShareHappeningNow.id]: { defaultValue: enabled }, + }); + + return gb; +}; + +const renderChannelTab = (enabled: boolean): RenderResult => { + (useRouter as jest.Mock).mockReturnValue({ + query: { channel: 'agents', highlight: 'h1' }, + pathname: '/highlights/[channel]', + push: jest.fn(), + asPath: '/highlights/agents', + } as unknown as NextRouter); + + mockPageQuery(); + mockChannelQuery(); + + return render( + + + , + ); +}; + +describe('HighlightsPage sharing', () => { + it('should render no share controls when the flag is off', async () => { + renderChannelTab(false); + + expect(await screen.findByText(summary)).toBeInTheDocument(); + expect(screen.queryByLabelText(PAGE_SHARE_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(TOPIC_SHARE_LABEL)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(ITEM_SHARE_LABEL)).not.toBeInTheDocument(); + }); + + it('should render exactly one control per level when enabled', async () => { + renderChannelTab(true); + + // Only the route-expanded highlight exposes an item-level control, so the + // three levels never stack up into a row of share buttons. + expect(await screen.findByText(summary)).toBeInTheDocument(); + await waitFor(() => + expect(screen.getAllByLabelText(PAGE_SHARE_LABEL)).toHaveLength(1), + ); + expect(screen.getAllByLabelText(TOPIC_SHARE_LABEL)).toHaveLength(1); + expect(screen.getAllByLabelText(ITEM_SHARE_LABEL)).toHaveLength(1); + }); +}); diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index eb11f9e7566..f26ef3b24b2 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -14,6 +14,13 @@ import { import { Tab, TabContainer } from '../tabs/TabContainer'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; +import { HighlightShareButton } from './HighlightShareButton'; +import { useSharingVisibility } from '../../hooks/useSharingVisibility'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureShareHappeningNow } from '../../lib/featureManagement'; +import { webappUrl } from '../../lib/constants'; +import { ButtonSize } from '../buttons/Button'; +import { ReferralCampaignKey } from '../../lib/referral'; const MAJOR_HEADLINES_LABEL = 'Headlines'; const ALL_HIGHLIGHTS_LABEL = 'All'; @@ -48,12 +55,14 @@ interface HighlightFeedListProps { highlights: PostHighlightFeed[]; loading: boolean; expandedId?: string; + showShare?: boolean; } const HighlightFeedList = ({ highlights, loading, expandedId, + showShare, }: HighlightFeedListProps): ReactElement => { if (loading) { return ( @@ -80,6 +89,7 @@ const HighlightFeedList = ({ key={highlight.id} highlight={highlight} defaultExpanded={highlight.id === expandedId} + showShare={showShare} /> ))} @@ -90,24 +100,29 @@ const MajorHeadlinesTab = ({ highlights, loading, expandedId, + showShare, }: { highlights: PostHighlightFeed[]; loading: boolean; expandedId?: string; + showShare?: boolean; }): ReactElement => ( ); const ChannelTab = ({ channel, expandedId, + showShare, }: { channel: ChannelConfiguration; expandedId?: string; + showShare?: boolean; }): ReactElement => { const { data, isFetching } = useChannelHighlights(channel.channel); const highlights = data?.postHighlights ?? []; @@ -116,12 +131,19 @@ const ChannelTab = ({ return ( <> {channel.digest && ( - + )} ); @@ -130,9 +152,11 @@ const ChannelTab = ({ const AllHighlightsTab = ({ active, expandedId, + showShare, }: { active: boolean; expandedId?: string; + showShare?: boolean; }): ReactElement => { const { data, isFetching } = useQuery({ ...postHighlightsFeedQueryOptions(), @@ -148,6 +172,7 @@ const AllHighlightsTab = ({ highlights={highlights} loading={isFetching && !data} expandedId={expandedId} + showShare={showShare} /> ); }; @@ -171,12 +196,42 @@ export const HighlightsPage = (): ReactElement => { ? ALL_HIGHLIGHTS_LABEL : channelLabel ?? MAJOR_HEADLINES_LABEL; + // One flag evaluation for the whole surface: the per-topic and per-item + // controls receive the resolved boolean as a prop so nothing re-evaluates + // GrowthBook once per highlight row. + const { isEnabled: isSharingVisible } = useSharingVisibility(); + const { value: isShareHappeningNowEnabled } = useConditionalFeature({ + feature: featureShareHappeningNow, + shouldEvaluate: isSharingVisible, + }); + const showShare = isSharingVisible && isShareHappeningNowEnabled; + + const activePath = (() => { + if (isAllTab) { + return ALL_HIGHLIGHTS_URL; + } + + return channel ? `${HIGHLIGHTS_BASE_URL}/${channel}` : HIGHLIGHTS_BASE_URL; + })(); + return (

Happening Now

+ {showShare && ( + + )}
{ highlights={majorHeadlines} loading={majorLoading} expandedId={expandedId} + showShare={showShare} /> , - + , ...channels.map((ch) => ( { label={ch.displayName} url={`${HIGHLIGHTS_BASE_URL}/${ch.channel}`} > - + )), ]} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..538a6374dab 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,12 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Adds share/copy affordances to the Happening Now surface at three levels: the +// whole page (header), a channel's digest CTA, and an individual expanded +// highlight. Also gated by the `sharing_visibility` master switch. Keep the +// default `false` — GrowthBook ramps it. +export const featureShareHappeningNow = new Feature( + 'share_happening_now', + false, +); diff --git a/packages/storybook/stories/components/HappeningNowShare.stories.tsx b/packages/storybook/stories/components/HappeningNowShare.stories.tsx new file mode 100644 index 00000000000..51839dd75c4 --- /dev/null +++ b/packages/storybook/stories/components/HappeningNowShare.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DigestCTA } from '@dailydotdev/shared/src/components/highlights/DigestCTA'; +import { HighlightItem } from '@dailydotdev/shared/src/components/highlights/HighlightItem'; +import { HighlightShareButton } from '@dailydotdev/shared/src/components/highlights/HighlightShareButton'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import type { PostHighlightFeed } from '@dailydotdev/shared/src/graphql/highlights'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { fn } from 'storybook/test'; + +const CHANNEL_LINK = 'https://app.daily.dev/highlights/agents'; +const PAGE_LINK = 'https://app.daily.dev/highlights'; + +const digest = { + frequency: 'daily', + source: { + id: 'source-1', + name: 'AI agents', + image: 'https://daily.dev/image.jpg', + handle: 'ai-agents', + permalink: 'https://app.daily.dev/sources/ai-agents', + }, +}; + +const highlight: PostHighlightFeed = { + id: 'highlight-1', + channel: 'agents', + headline: 'Agent frameworks converge on a single tool-calling spec', + highlightedAt: new Date().toISOString(), + significance: 'major', + post: { + id: 'post-1', + type: 'article', + commentsPermalink: 'https://app.daily.dev/posts/post-1', + summary: + 'The three biggest agent frameworks now emit the same tool-calling payload, which means adapters written for one runtime drop straight into the others.', + }, +}; + +// The Happening Now header is part of `HighlightsPage`, which needs the router +// and the highlights queries. The header row is reproduced here only so the +// page-level control can be reviewed next to the other two levels. +const PageHeader = (): React.ReactElement => ( +
+

+ Happening Now +

+ +
+); + +interface SurfaceProps { + showShare: boolean; +} + +const HappeningNowSurface = ({ + showShare, +}: SurfaceProps): React.ReactElement => ( +
+ {showShare ? ( + + ) : ( +
+

+ Happening Now +

+
+ )} + + + +
+); + +const meta: Meta = { + title: 'Components/Share/HappeningNow', + component: HappeningNowSurface, + args: { showShare: true }, + argTypes: { + showShare: { + control: 'boolean', + description: + 'Resolved value of `share_happening_now` (AND the `sharing_visibility` master switch). `HighlightsPage` evaluates it once and threads it down, so these stories take the resolved boolean directly — no GrowthBook mock is involved and the off state is a real control.', + }, + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const LogContext = getLogContextStatic(); + + return ( + + + false, + }} + > +
+ +
+
+
+
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +// All three levels at once — page header, channel digest CTA, expanded +// highlight. Only the expanded highlight exposes an item-level control, so the +// levels never pile up into a row of share buttons. +export const AllLevels: Story = { + args: { showShare: true }, +}; + +// True control: the same surface with the resolved flag off. Nothing about the +// existing markup changes. +export const FlagOff: Story = { + args: { showShare: false }, +}; + +// Level 1 — the whole Happening Now page. +export const PageLevel: Story = { + render: () => , +}; + +// Level 2 — a single channel/topic, next to the digest subscribe control. +export const TopicLevel: Story = { + render: () => ( + + ), +}; + +// Level 3 — a single highlight, next to "Read more". +export const ItemLevel: Story = { + render: () => ( + + ), +}; + +// `ShareActions` switches to a one-tap native share sheet below the laptop +// breakpoint (it reads the real viewport via `useViewSize`), so narrow the +// preview pane to exercise it. The frame below only previews the layout at +// mobile width. +export const MobileWidth: Story = { + args: { showShare: true }, + render: (args) => ( +
+ +
+ ), +};