diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 51d7fa2d434..3dce965cfbe 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -95,6 +95,8 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { ExploreSectionTabs } from './header/ExploreSectionTabs'; import { ExploreSortDropdown } from './header/ExploreSortDropdown'; +import { ExploreFeedShareButton } from './header/ExploreFeedShareButton'; +import { ButtonSize, ButtonVariant } from './buttons/Button'; const FeedExploreHeader = dynamic( () => @@ -764,6 +766,16 @@ export default function MainFeedLayout({ {showExploreV2PageHeader && (
+ {/* The share affordance joins the sort controls' right-side cluster; + the header's own gap-2 spaces it, and it self-gates on the + share_discovery flag so flag-off DOM is unchanged. */} + {isAnyExplore && ( + + )} {isAnyExplore && }
)} diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx new file mode 100644 index 00000000000..63a14271bb3 --- /dev/null +++ b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { ArchiveFeedPage } from './ArchiveFeedPage'; +import { ArchivePeriodType, ArchiveScopeType } from '../../graphql/archive'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +const logEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +const gbWithFeatures = (features: Record): GrowthBook => + new GrowthBook({ + features: Object.fromEntries( + Object.entries(features).map(([id, value]) => [ + id, + { defaultValue: value }, + ]), + ), + }); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderComponent = (gb?: GrowthBook): RenderResult => { + const client = new QueryClient(); + + return render( + + + , + ); +}; + +it('keeps the original heading and renders no share control when flags are off', () => { + renderComponent(); + + expect(screen.queryByLabelText('Share this archive')).not.toBeInTheDocument(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toHaveClass('mx-4 font-bold typo-title2 tablet:typo-title1', { + exact: true, + }); +}); + +it('copies the canonical archive page url and logs when flags are on', async () => { + renderComponent( + gbWithFeatures({ sharing_visibility: true, share_discovery: true }), + ); + + const controls = screen.getAllByLabelText('Share this archive'); + expect(controls).toHaveLength(1); + + await act(async () => { + fireEvent.click(controls[0]); + }); + + // webappUrl is '/' under Jest, so the canonical link is the bare path + await waitFor(() => + expect(writeText).toHaveBeenCalledWith('/tags/react/best-of/2026/05'), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareLog, + target_id: '/tags/react/best-of/2026/05', + extra: JSON.stringify({ + origin: Origin.BestOfArchive, + provider: ShareProvider.CopyLink, + }), + }); +}); diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.tsx index d9d00076de9..46fa0d06e0c 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -4,13 +4,25 @@ import classNames from 'classnames'; import type { Archive, ArchiveItem } from '../../graphql/archive'; import { ArchivePeriodType } from '../../graphql/archive'; import type { ArchiveScopeInfo } from '../../lib/archive'; -import { getArchiveTitle, getArchiveIndexUrl } from '../../lib/archive'; +import { + getArchiveDescription, + getArchiveIndexUrl, + getArchiveTitle, + getArchiveUrl, +} from '../../lib/archive'; import { ArchiveNavigation } from './ArchiveNavigation'; import { ArchivePostItem } from './ArchivePostItem'; import { ElementPlaceholder } from '../ElementPlaceholder'; import Link from '../utilities/Link'; import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; +import { ShareActions } from '../share/ShareActions'; +import { ButtonSize } from '../buttons/Button'; +import { useShareDiscovery } from '../../hooks/useShareDiscovery'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { webappUrl } from '../../lib/constants'; +import { ReferralCampaignKey } from '../../lib/referral'; interface ArchiveFeedPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -95,6 +107,26 @@ export function ArchiveFeedPage({ scopeId, } as ArchiveScopeInfo); const items = (archive?.items ?? []).filter((item) => item.post); + const { isEnabled: canShare } = useShareDiscovery(); + const { logEvent } = useLogContext(); + const archivePath = getArchiveUrl( + { scopeType, scopeId } as ArchiveScopeInfo, + periodType, + year, + month, + ); + const heading = ( +

+ Best of {scopeName} — {title.replace('Best of ', '')} +

+ ); return (
{/* Header */} -

- Best of {scopeName} — {title.replace('Best of ', '')} -

+ {canShare ? ( +
+ {heading} + + logEvent({ + event_name: LogEvent.ShareLog, + target_id: archivePath, + extra: JSON.stringify({ + origin: Origin.BestOfArchive, + provider, + }), + }) + } + /> +
+ ) : ( + heading + )} {/* Top navigation */} ): GrowthBook => + new GrowthBook({ + features: Object.fromEntries( + Object.entries(features).map(([id, value]) => [ + id, + { defaultValue: value }, + ]), + ), + }); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderComponent = (gb?: GrowthBook): RenderResult => { + const client = new QueryClient(); + + return render( + + + , + ); +}; + +it('keeps the original heading and renders no share control when flags are off', () => { + renderComponent(); + + expect(screen.queryByLabelText('Share this archive')).not.toBeInTheDocument(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toHaveClass('mx-4 font-bold typo-title2 tablet:typo-title1', { + exact: true, + }); +}); + +it('copies the canonical archive index url and logs when flags are on', async () => { + renderComponent( + gbWithFeatures({ sharing_visibility: true, share_discovery: true }), + ); + + const controls = screen.getAllByLabelText('Share this archive'); + expect(controls).toHaveLength(1); + + await act(async () => { + fireEvent.click(controls[0]); + }); + + // webappUrl is '/' under Jest, so the canonical link is the bare path + await waitFor(() => expect(writeText).toHaveBeenCalledWith('/posts/best-of')); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareLog, + target_id: '/posts/best-of', + extra: JSON.stringify({ + origin: Origin.BestOfArchive, + provider: ShareProvider.CopyLink, + }), + }); +}); diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.tsx index 46ab41643b3..0a703d60737 100644 --- a/packages/shared/src/components/archive/ArchiveIndexPage.tsx +++ b/packages/shared/src/components/archive/ArchiveIndexPage.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { Archive } from '../../graphql/archive'; import type { ArchiveScopeInfo, ArchivesByYear } from '../../lib/archive'; import { + getArchiveIndexUrl, getArchiveUrlFromArchive, getMonthName, groupArchivesByYear, @@ -13,6 +14,13 @@ import Link from '../utilities/Link'; import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; import { ElementPlaceholder } from '../ElementPlaceholder'; +import { ShareActions } from '../share/ShareActions'; +import { ButtonSize } from '../buttons/Button'; +import { useShareDiscovery } from '../../hooks/useShareDiscovery'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { webappUrl } from '../../lib/constants'; +import { ReferralCampaignKey } from '../../lib/referral'; interface ArchiveIndexPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -159,13 +167,52 @@ export function ArchiveIndexPage({ className, }: ArchiveIndexPageProps): ReactElement { const groups = groupArchivesByYear(archives); + const { isEnabled: canShare } = useShareDiscovery(); + const { logEvent } = useLogContext(); + const indexPath = getArchiveIndexUrl({ + scopeType, + scopeId, + } as ArchiveScopeInfo); + const heading = ( +

+ Best of {scopeName} — Archive +

+ ); return (
{/* Header */} -

- Best of {scopeName} — Archive -

+ {canShare ? ( +
+ {heading} + + logEvent({ + event_name: LogEvent.ShareLog, + target_id: indexPath, + extra: JSON.stringify({ + origin: Origin.BestOfArchive, + provider, + }), + }) + } + /> +
+ ) : ( + heading + )} {/* Archive grid by year */} + logEvent({ + event_name: LogEvent.ShareLog, + target_id: sharePath, + extra: JSON.stringify({ origin: Origin.ExploreFeed, provider }), + }) + } + /> + ); +} diff --git a/packages/shared/src/components/header/FeedExploreHeader.spec.tsx b/packages/shared/src/components/header/FeedExploreHeader.spec.tsx new file mode 100644 index 00000000000..c36b2cc9dbf --- /dev/null +++ b/packages/shared/src/components/header/FeedExploreHeader.spec.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { ExploreTabs, FeedExploreHeader } from './FeedExploreHeader'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +const mockDisplayToast = jest.fn(); +jest.mock('../../hooks/useToastNotification', () => ({ + ...jest.requireActual('../../hooks/useToastNotification'), + useToastNotification: () => ({ + displayToast: mockDisplayToast, + dismissToast: jest.fn(), + }), +})); + +const logEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +const gbWithFeatures = (features: Record): GrowthBook => + new GrowthBook({ + features: Object.fromEntries( + Object.entries(features).map(([id, value]) => [ + id, + { defaultValue: value }, + ]), + ), + }); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/posts/upvoted', + asPath: '/posts/upvoted', + query: {}, + push: jest.fn(), + replace: jest.fn(), + } as unknown as NextRouter), + ); +}); + +const renderComponent = (gb?: GrowthBook): RenderResult => { + const client = new QueryClient(); + + return render( + + + , + ); +}; + +describe('FeedExploreHeader share affordance flag-off', () => { + it('renders no share control and keeps the original right-side span', () => { + renderComponent(); + + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); + // The period dropdown trigger is the only button; its wrapper span must + // keep the pre-initiative class list so flag-off DOM stays identical. + const dropdownTrigger = screen.getByRole('button'); + expect(dropdownTrigger.closest('span')?.className).toBe('ml-auto'); + }); + + it('renders no share control when only the master flag is on', () => { + renderComponent(gbWithFeatures({ sharing_visibility: true })); + + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); + }); + + it('renders no share control when only share_discovery is on', () => { + renderComponent(gbWithFeatures({ share_discovery: true })); + + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); + }); +}); + +describe('FeedExploreHeader share affordance flag-on', () => { + const gb = gbWithFeatures({ + sharing_visibility: true, + share_discovery: true, + }); + + it('renders exactly one copy-link control', () => { + renderComponent(gb); + + expect(screen.getAllByLabelText('Share this feed')).toHaveLength(1); + }); + + it('copies the canonical explore url, toasts and logs on tap', async () => { + renderComponent(gb); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share this feed')); + }); + + // webappUrl is '/' under Jest, so the canonical link is the bare path + await waitFor(() => + expect(writeText).toHaveBeenCalledWith('/posts/upvoted'), + ); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied link to clipboard', + expect.anything(), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareLog, + target_id: '/posts/upvoted', + extra: JSON.stringify({ + origin: Origin.ExploreFeed, + provider: ShareProvider.CopyLink, + }), + }); + }); + + it('uses the native share sheet on mobile', async () => { + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 2, + configurable: true, + }); + + renderComponent(gb); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share this feed')); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: expect.stringContaining('/posts/upvoted'), + }), + ); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.ShareLog, + extra: JSON.stringify({ + origin: Origin.ExploreFeed, + provider: ShareProvider.Native, + }), + }), + ); + + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + delete (navigator as { share?: unknown }).share; + }); +}); diff --git a/packages/shared/src/components/header/FeedExploreHeader.tsx b/packages/shared/src/components/header/FeedExploreHeader.tsx index f8a70eff8cf..d489dfa4681 100644 --- a/packages/shared/src/components/header/FeedExploreHeader.tsx +++ b/packages/shared/src/components/header/FeedExploreHeader.tsx @@ -10,10 +10,13 @@ import { Tab, TabContainer } from '../tabs/TabContainer'; import { checkIsExtension } from '../../lib/func'; import { getFeedName } from '../../lib/feed'; import { Dropdown } from '../fields/Dropdown'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ExploreFeedShareButton } from './ExploreFeedShareButton'; import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; import { periodTexts } from '../layout/common'; import { OtherFeedPage } from '../../lib/query'; import { useFeedLayout } from '../../hooks'; +import { useShareDiscovery } from '../../hooks/useShareDiscovery'; export enum ExploreTabs { Popular = 'Popular', @@ -78,9 +81,13 @@ export function FeedExploreHeader({ defaultValue: 0, }); const { isListMode } = useFeedLayout(); + const { isEnabled: canShareFeed } = useShareDiscovery(); const shouldShowDropdown = withDateRange.includes(path as OtherFeedPage) || withDateRange.includes(tab); + // Webapp derives the active sort from the URL; the extension drives it via + // the `tab` state instead, so fall back to that there. + const sharePath = tabToUrl[urlToTab[router.pathname] ?? tab]; return (
@@ -125,7 +132,21 @@ export function FeedExploreHeader({ )} {showDropdown && ( - + + {canShareFeed && ( + + )} {shouldShowDropdown && ( { + const { isEnabled: isSharingEnabled } = useSharingVisibility(shouldEvaluate); + const { value } = useConditionalFeature({ + feature: featureShareDiscovery, + shouldEvaluate: shouldEvaluate && isSharingEnabled, + }); + + return { isEnabled: isSharingEnabled && value }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..37f9f8a74e7 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,9 @@ 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); + +// Sharing-visibility per-topic flag: copy-link/share on discovery surfaces — +// the Explore feed headers and the best-of archive pages. Also gated by the +// `sharing_visibility` master kill-switch. Keep the default `false` — +// GrowthBook ramps it. +export const featureShareDiscovery = new Feature('share_discovery', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa61..d51c2ac3de3 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,8 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + ExploreFeed = 'explore feed', + BestOfArchive = 'best of archive', } export enum LogEvent { diff --git a/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx b/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx new file mode 100644 index 00000000000..da4a5c24b80 --- /dev/null +++ b/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx @@ -0,0 +1,75 @@ +import type { FeedData } from '@dailydotdev/shared/src/graphql/posts'; +import { + ANONYMOUS_FEED_QUERY, + RankingAlgorithm, +} from '@dailydotdev/shared/src/graphql/feed'; +import nock from 'nock'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import ad from '@dailydotdev/shared/__tests__/fixture/ad'; +import defaultFeedPage from '@dailydotdev/shared/__tests__/fixture/feed'; +import type { MockedGraphQLResponse } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import Popular from '../pages/popular'; + +// The share_discovery control must only render on the Explore (discovery) +// surfaces — its component-level coverage lives in the shared +// FeedExploreHeader/Archive specs. This guards the composition: a non-explore +// feed page must not grow the control even with the gate forced fully on. +jest.mock('@dailydotdev/shared/src/hooks/useShareDiscovery', () => ({ + useShareDiscovery: () => ({ isEnabled: true }), +})); + +beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + nock.cleanAll(); + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/popular', + query: {}, + replace: jest.fn(), + push: jest.fn(), + } as unknown as NextRouter), + ); +}); + +const createFeedMock = (): MockedGraphQLResponse => ({ + request: { + query: ANONYMOUS_FEED_QUERY, + variables: { + first: 7, + after: '', + loggedIn: false, + version: 15, + ranking: RankingAlgorithm.Popularity, + columns: 1, + }, + }, + result: { + data: { + page: defaultFeedPage, + }, + }, +}); + +it('does not render the copy-link control on non-explore feed pages with flags on', async () => { + const client = new QueryClient(); + mockGraphQL(createFeedMock()); + nock('http://localhost:3000').get('/v1/a').reply(200, [ad]); + + render( + + {Popular.getLayout(, {}, Popular.layoutProps)} + , + ); + + const elements = await screen.findAllByTestId('postItem'); + expect(elements.length).toBeTruthy(); + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); +});