{/* 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();
+});