From fa003e577635cb96e43bd53dec44e871c0b862ff Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 19:25:34 +0300 Subject: [PATCH 1/2] feat(share): copy-link on discovery feeds and best-of archive (PR 16) Adds the sharing-visibility affordance to the discovery surfaces behind a new share_discovery flag (default false, AND-gated with the sharing_visibility master kill-switch via useSharingVisibility): - ExploreFeedShareButton: self-gated ShareActions wrapper that shares the canonical webapp URL of the active Explore sort and logs the generic ShareLog event (Origin.ExploreFeed, target_id = feed path) - FeedExploreHeader (non-v2, laptop + mobile sticky): control joins the right-side period-dropdown span; span only becomes a flex cluster when the flag is on so flag-off DOM is unchanged - MainFeedLayout v2 explore strip: control renders before the sort dropdown, only on isAnyExplore pages - ArchiveIndexPage / ArchiveFeedPage: control next to the h1, sharing the canonical archive URL, logging Origin.BestOfArchive - Jest: flag-off DOM parity, AND-gating, copy -> clipboard + toast, native share on mobile, exactly one control, and a webapp guard that non-explore feed pages never compose the control Co-Authored-By: Claude Fable 5 --- .../shared/src/components/MainFeedLayout.tsx | 12 ++ .../archive/ArchiveFeedPage.spec.tsx | 88 +++++++++ .../components/archive/ArchiveFeedPage.tsx | 58 +++++- .../archive/ArchiveIndexPage.spec.tsx | 99 +++++++++++ .../components/archive/ArchiveIndexPage.tsx | 50 +++++- .../header/ExploreFeedShareButton.tsx | 54 ++++++ .../header/FeedExploreHeader.spec.tsx | 168 ++++++++++++++++++ .../components/header/FeedExploreHeader.tsx | 23 ++- .../shared/src/hooks/useShareDiscovery.ts | 21 +++ packages/shared/src/lib/featureManagement.ts | 6 + packages/shared/src/lib/log.ts | 2 + .../__tests__/ShareDiscoverySurfaces.tsx | 75 ++++++++ 12 files changed, 648 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx create mode 100644 packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx create mode 100644 packages/shared/src/components/header/ExploreFeedShareButton.tsx create mode 100644 packages/shared/src/components/header/FeedExploreHeader.spec.tsx create mode 100644 packages/shared/src/hooks/useShareDiscovery.ts create mode 100644 packages/webapp/__tests__/ShareDiscoverySurfaces.tsx 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..041a08f47c7 --- /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('Copy link')).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('Copy link'); + 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..688c74dfa22 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -4,13 +4,24 @@ 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'; interface ArchiveFeedPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -95,6 +106,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('Copy link')).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('Copy link'); + 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..bac8a661dab 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,12 @@ 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'; interface ArchiveIndexPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -159,13 +166,50 @@ 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..e4c042ce2f4 --- /dev/null +++ b/packages/shared/src/components/header/FeedExploreHeader.spec.tsx @@ -0,0 +1,168 @@ +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('Copy link to 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('Copy link to feed'), + ).not.toBeInTheDocument(); + }); + + it('renders no share control when only share_discovery is on', () => { + renderComponent(gbWithFeatures({ share_discovery: true })); + + expect( + screen.queryByLabelText('Copy link to 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('Copy link to feed')).toHaveLength(1); + }); + + it('copies the canonical explore url, toasts and logs on tap', async () => { + renderComponent(gb); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link to 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('Copy link to 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..4011d767937 --- /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('Copy link to feed')).not.toBeInTheDocument(); +}); From 0cc70b0e2553c25738d3b5fe378d80d541496292 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 22:49:58 +0300 Subject: [PATCH 2/2] fix(share): attribute discovery shares and use Share-family labels All three discovery surfaces now pass a referral cid like comparable surfaces, and the archive controls get explicit Share-family labels instead of the primitive's Copy link default. Co-Authored-By: Claude Opus 4.8 --- .../archive/ArchiveFeedPage.spec.tsx | 4 ++-- .../src/components/archive/ArchiveFeedPage.tsx | 3 +++ .../archive/ArchiveIndexPage.spec.tsx | 4 ++-- .../components/archive/ArchiveIndexPage.tsx | 3 +++ .../header/ExploreFeedShareButton.tsx | 4 +++- .../header/FeedExploreHeader.spec.tsx | 18 ++++++------------ .../__tests__/ShareDiscoverySurfaces.tsx | 2 +- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx index 041a08f47c7..63a14271bb3 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx @@ -54,7 +54,7 @@ const renderComponent = (gb?: GrowthBook): RenderResult => { it('keeps the original heading and renders no share control when flags are off', () => { renderComponent(); - expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + 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, @@ -66,7 +66,7 @@ it('copies the canonical archive page url and logs when flags are on', async () gbWithFeatures({ sharing_visibility: true, share_discovery: true }), ); - const controls = screen.getAllByLabelText('Copy link'); + const controls = screen.getAllByLabelText('Share this archive'); expect(controls).toHaveLength(1); await act(async () => { diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.tsx index 688c74dfa22..46fa0d06e0c 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -22,6 +22,7 @@ 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']; @@ -141,6 +142,8 @@ export function ArchiveFeedPage({ logEvent({ diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx index a2e022d2d64..aee3081833c 100644 --- a/packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx +++ b/packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx @@ -67,7 +67,7 @@ const renderComponent = (gb?: GrowthBook): RenderResult => { it('keeps the original heading and renders no share control when flags are off', () => { renderComponent(); - expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + 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, @@ -79,7 +79,7 @@ it('copies the canonical archive index url and logs when flags are on', async () gbWithFeatures({ sharing_visibility: true, share_discovery: true }), ); - const controls = screen.getAllByLabelText('Copy link'); + const controls = screen.getAllByLabelText('Share this archive'); expect(controls).toHaveLength(1); await act(async () => { diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.tsx index bac8a661dab..0a703d60737 100644 --- a/packages/shared/src/components/archive/ArchiveIndexPage.tsx +++ b/packages/shared/src/components/archive/ArchiveIndexPage.tsx @@ -20,6 +20,7 @@ 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']; @@ -194,6 +195,8 @@ export function ArchiveIndexPage({ logEvent({ diff --git a/packages/shared/src/components/header/ExploreFeedShareButton.tsx b/packages/shared/src/components/header/ExploreFeedShareButton.tsx index 5c35aaaf7e4..dbc49359773 100644 --- a/packages/shared/src/components/header/ExploreFeedShareButton.tsx +++ b/packages/shared/src/components/header/ExploreFeedShareButton.tsx @@ -6,6 +6,7 @@ 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 ExploreFeedShareButtonProps { /** Bare app path of the active Explore sort, e.g. `/posts/upvoted`. */ @@ -38,7 +39,8 @@ export function ExploreFeedShareButton({ text="Explore what millions of developers are reading on daily.dev" // Feed cards ship their own per-post "Copy link" buttons, so the header // control needs a distinct accessible name. - label="Copy link to feed" + label="Share this feed" + cid={ReferralCampaignKey.Generic} buttonVariant={buttonVariant} buttonSize={buttonSize} className={className} diff --git a/packages/shared/src/components/header/FeedExploreHeader.spec.tsx b/packages/shared/src/components/header/FeedExploreHeader.spec.tsx index e4c042ce2f4..c36b2cc9dbf 100644 --- a/packages/shared/src/components/header/FeedExploreHeader.spec.tsx +++ b/packages/shared/src/components/header/FeedExploreHeader.spec.tsx @@ -67,9 +67,7 @@ describe('FeedExploreHeader share affordance flag-off', () => { it('renders no share control and keeps the original right-side span', () => { renderComponent(); - expect( - screen.queryByLabelText('Copy link to feed'), - ).not.toBeInTheDocument(); + 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'); @@ -79,17 +77,13 @@ describe('FeedExploreHeader share affordance flag-off', () => { it('renders no share control when only the master flag is on', () => { renderComponent(gbWithFeatures({ sharing_visibility: true })); - expect( - screen.queryByLabelText('Copy link to feed'), - ).not.toBeInTheDocument(); + 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('Copy link to feed'), - ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); }); }); @@ -102,14 +96,14 @@ describe('FeedExploreHeader share affordance flag-on', () => { it('renders exactly one copy-link control', () => { renderComponent(gb); - expect(screen.getAllByLabelText('Copy link to feed')).toHaveLength(1); + 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('Copy link to feed')); + fireEvent.click(screen.getByLabelText('Share this feed')); }); // webappUrl is '/' under Jest, so the canonical link is the bare path @@ -141,7 +135,7 @@ describe('FeedExploreHeader share affordance flag-on', () => { renderComponent(gb); await act(async () => { - fireEvent.click(screen.getByLabelText('Copy link to feed')); + fireEvent.click(screen.getByLabelText('Share this feed')); }); await waitFor(() => diff --git a/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx b/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx index 4011d767937..da4a5c24b80 100644 --- a/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx +++ b/packages/webapp/__tests__/ShareDiscoverySurfaces.tsx @@ -71,5 +71,5 @@ it('does not render the copy-link control on non-explore feed pages with flags o const elements = await screen.findAllByTestId('postItem'); expect(elements.length).toBeTruthy(); - expect(screen.queryByLabelText('Copy link to feed')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Share this feed')).not.toBeInTheDocument(); });