{showShareButton && (
}
+ icon={showCopyIcon ?
:
}
size={ButtonSize.Medium}
onClick={() => copyLink({ post })}
+ aria-label="Copy link"
/>
)}
diff --git a/packages/shared/src/components/share/ShareActions.spec.tsx b/packages/shared/src/components/share/ShareActions.spec.tsx
new file mode 100644
index 00000000000..5a86a8de95c
--- /dev/null
+++ b/packages/shared/src/components/share/ShareActions.spec.tsx
@@ -0,0 +1,81 @@
+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 { ShareActions } from './ShareActions';
+import { TestBootProvider } from '../../../__tests__/helpers/boot';
+import { ShareProvider } from '../../lib/share';
+import { useViewSize } from '../../hooks/useViewSize';
+
+jest.mock('../../hooks/useViewSize', () => {
+ const actual = jest.requireActual('../../hooks/useViewSize');
+ return { __esModule: true, ...actual, useViewSize: jest.fn() };
+});
+
+const useViewSizeMock = useViewSize as jest.Mock;
+const writeText = jest.fn().mockResolvedValue(undefined);
+const onShare = jest.fn();
+const link = 'https://daily.dev/posts/abc';
+const text = 'Check this out';
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ useViewSizeMock.mockReturnValue(true); // default: laptop
+ Object.assign(navigator, {
+ clipboard: { writeText },
+ });
+});
+
+const renderComponent = (
+ props: Partial
[0]> = {},
+): RenderResult => {
+ const client = new QueryClient();
+ return render(
+
+
+ ,
+ );
+};
+
+describe('ShareActions inline variant', () => {
+ it('renders copy link plus a compact set of social networks', () => {
+ renderComponent({ variant: 'inline' });
+
+ expect(screen.getByText('Copy link')).toBeInTheDocument();
+ expect(screen.getByText('X')).toBeInTheDocument();
+ expect(screen.getByText('WhatsApp')).toBeInTheDocument();
+ });
+
+ it('copies the link and reports the CopyLink provider', async () => {
+ renderComponent({ variant: 'inline' });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Copy link'));
+ });
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
+ expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink);
+ });
+});
+
+describe('ShareActions icon variant on mobile', () => {
+ beforeEach(() => useViewSizeMock.mockReturnValue(false));
+
+ it('copies on a single tap when native share is unavailable', async () => {
+ renderComponent();
+
+ const trigger = screen.getByLabelText('Copy link');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
+ expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink);
+ });
+});
diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx
new file mode 100644
index 00000000000..50ee53e30e8
--- /dev/null
+++ b/packages/shared/src/components/share/ShareActions.tsx
@@ -0,0 +1,161 @@
+import type { ReactElement } from 'react';
+import React, { useRef, useState } from 'react';
+import classNames from 'classnames';
+import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
+import { PopoverContent } from '../popover/Popover';
+import { SocialShareList } from '../widgets/SocialShareList';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import { CopyIcon } from '../icons';
+import { Tooltip } from '../tooltip/Tooltip';
+import { Typography, TypographyType } from '../typography/Typography';
+import { useViewSize, ViewSize } from '../../hooks/useViewSize';
+import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
+import { shouldUseNativeShare } from '../../lib/func';
+import { ShareProvider } from '../../lib/share';
+import type { ReferralCampaignKey } from '../../lib/referral';
+
+export type ShareActionsVariant = 'icon' | 'inline';
+
+export interface ShareActionsProps {
+ link: string;
+ /** Share text / description used for native share + pre-filled network text. */
+ text: string;
+ cid?: ReferralCampaignKey;
+ variant?: ShareActionsVariant;
+ /** Desktop only: reveal the popover on hover as well as click. */
+ openOnHover?: boolean;
+ buttonVariant?: ButtonVariant;
+ buttonSize?: ButtonSize;
+ /** Tooltip + accessible label for the icon-only trigger. */
+ label?: string;
+ emailTitle?: string;
+ emailSummary?: string;
+ className?: string;
+ /** Called for any share/copy so the caller can log with its own origin. */
+ onShare?: (provider: ShareProvider) => void;
+}
+
+const HOVER_CLOSE_DELAY = 120;
+
+export function ShareActions({
+ link,
+ text,
+ cid,
+ variant = 'icon',
+ openOnHover = false,
+ buttonVariant = ButtonVariant.Tertiary,
+ buttonSize = ButtonSize.Small,
+ label = 'Copy link',
+ emailTitle,
+ emailSummary,
+ className,
+ onShare,
+}: ShareActionsProps): ReactElement {
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const [open, setOpen] = useState(false);
+ const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid });
+ const closeTimeout = useRef>();
+
+ const list = (
+ {
+ onShare?.(ShareProvider.CopyLink);
+ shareOrCopy();
+ }}
+ onNativeShare={() => {
+ onShare?.(ShareProvider.Native);
+ shareOrCopy();
+ }}
+ onClickSocial={(provider) => onShare?.(provider)}
+ />
+ );
+
+ if (variant === 'inline') {
+ return (
+
+ {list}
+
+ );
+ }
+
+ // Mobile: a single tap goes straight to the native share sheet (or copy when
+ // native share is unavailable) — no popover, per sharing UX guidance.
+ if (!isLaptop) {
+ return (
+
+ }
+ aria-label={label}
+ className={className}
+ onClick={() => {
+ onShare?.(
+ shouldUseNativeShare()
+ ? ShareProvider.Native
+ : ShareProvider.CopyLink,
+ );
+ shareOrCopy();
+ }}
+ />
+
+ );
+ }
+
+ const cancelClose = () => {
+ if (closeTimeout.current) {
+ clearTimeout(closeTimeout.current);
+ }
+ };
+ const hoverProps = openOnHover
+ ? {
+ onMouseEnter: () => {
+ cancelClose();
+ setOpen(true);
+ },
+ onMouseLeave: () => {
+ closeTimeout.current = setTimeout(
+ () => setOpen(false),
+ HOVER_CLOSE_DELAY,
+ );
+ },
+ }
+ : undefined;
+
+ return (
+
+
+
+ }
+ aria-label={label}
+ pressed={open}
+ className={className}
+ {...hoverProps}
+ />
+
+
+
+
+ Share
+
+ {list}
+
+
+ );
+}
diff --git a/packages/shared/src/hooks/useShareCopyIcon.ts b/packages/shared/src/hooks/useShareCopyIcon.ts
new file mode 100644
index 00000000000..bc1525a0d7f
--- /dev/null
+++ b/packages/shared/src/hooks/useShareCopyIcon.ts
@@ -0,0 +1,14 @@
+import { featureShareCopyIcon } from '../lib/featureManagement';
+import { useConditionalFeature } from './useConditionalFeature';
+
+// Resolves whether copy-link actions should render the new `CopyIcon` instead
+// of the legacy `LinkIcon`. Gated so the core-icon swap ramps behind
+// `share_copy_icon` (control = false = existing `LinkIcon`, no metric impact).
+export const useShareCopyIcon = (shouldEvaluate = true): boolean => {
+ const { value } = useConditionalFeature({
+ feature: featureShareCopyIcon,
+ shouldEvaluate,
+ });
+
+ return value;
+};
diff --git a/packages/shared/src/hooks/useSharingVisibility.ts b/packages/shared/src/hooks/useSharingVisibility.ts
new file mode 100644
index 00000000000..aa44187509b
--- /dev/null
+++ b/packages/shared/src/hooks/useSharingVisibility.ts
@@ -0,0 +1,22 @@
+import { featureSharingVisibility } from '../lib/featureManagement';
+import { useConditionalFeature } from './useConditionalFeature';
+
+interface UseSharingVisibility {
+ isEnabled: boolean;
+ isLoading: boolean;
+}
+
+// Master gate for the sharing-visibility initiative. Wraps the
+// `sharing_visibility` kill-switch so every surface can guard on it with one
+// call. Pass `shouldEvaluate` to avoid evaluating GrowthBook until the surface
+// would actually render (per repo convention).
+export const useSharingVisibility = (
+ shouldEvaluate = true,
+): UseSharingVisibility => {
+ const { value, isLoading } = useConditionalFeature({
+ feature: featureSharingVisibility,
+ shouldEvaluate,
+ });
+
+ return { isEnabled: shouldEvaluate && value, isLoading };
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index e30adb28f5f..97b38cbdd2b 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -293,3 +293,18 @@ export const featureNotificationsRedesign = new Feature(
// `analytics.impressions` field. Control hides it entirely. Keep the default
// `false` — GrowthBook ramps it.
export const featureCardImpressions = new Feature('card_impressions', false);
+
+// Master kill-switch for the sharing-visibility initiative (copy-link/share
+// affordances added across many surfaces). Each surface also ships behind its
+// own per-topic flag; this one can disable the whole initiative at once. Keep
+// the default `false` — GrowthBook ramps it.
+export const featureSharingVisibility = new Feature(
+ 'sharing_visibility',
+ false,
+);
+
+// Swaps the core copy-link glyph from `LinkIcon` to `CopyIcon` across share
+// surfaces (feed card, brief header, mobile share widget). This touches a core,
+// 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);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 4d37a4b92b2..9696707fa61 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -101,6 +101,13 @@ export enum Origin {
DailyPage = 'daily page',
EngagementBanner = 'engagement banner',
EngagementFeedStrip = 'engagement feed strip',
+ // sharing visibility initiative
+ ReadingStreak = 'reading streak',
+ HappeningNow = 'happening now',
+ Achievements = 'achievements',
+ GameCenter = 'game center',
+ DevCard = 'devcard',
+ CopyMyFeed = 'copy my feed',
}
export enum LogEvent {
diff --git a/packages/storybook/stories/components/ShareActions.stories.tsx b/packages/storybook/stories/components/ShareActions.stories.tsx
new file mode 100644
index 00000000000..0857b12c550
--- /dev/null
+++ b/packages/storybook/stories/components/ShareActions.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions';
+import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext';
+import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
+import { fn } from 'storybook/test';
+
+const meta: Meta = {
+ title: 'Components/Share/ShareActions',
+ component: ShareActions,
+ args: {
+ link: 'https://daily.dev/posts/how-to-ship-fast',
+ text: 'Check out this post on daily.dev',
+ onShare: fn(),
+ },
+ argTypes: {
+ variant: {
+ control: 'inline-radio',
+ options: ['icon', 'inline'],
+ description:
+ 'icon = copy-icon trigger with a popover (desktop) / native share (mobile); inline = the social row rendered directly',
+ },
+ openOnHover: {
+ control: 'boolean',
+ description: 'Desktop only: reveal the popover on hover as well as click',
+ },
+ label: { control: 'text' },
+ link: { control: 'text' },
+ text: { control: 'text' },
+ },
+ decorators: [
+ (Story) => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ // Mock the short-URL resolution so copy/social actions don't hit network.
+ queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123');
+
+ const LogContext = getLogContextStatic();
+
+ const mockUser = {
+ id: '1',
+ name: 'Test User',
+ username: 'testuser',
+ email: 'test@example.com',
+ image:
+ 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg',
+ providers: ['google'],
+ createdAt: '2024-01-01T00:00:00.000Z',
+ permalink: 'https://daily.dev/testuser',
+ } as unknown as LoggedUser;
+
+ return (
+
+
+ false,
+ }}
+ >
+
+
+
+
+
+
+ );
+ },
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// Icon trigger — click to open the share popover on desktop; a single tap opens
+// the native share sheet on mobile.
+export const Icon: Story = {
+ args: { variant: 'icon' },
+};
+
+// Popover also opens on hover (desktop) for the feed-card share-out pattern.
+export const IconOpenOnHover: Story = {
+ args: { variant: 'icon', openOnHover: true },
+};
+
+// The full social row, for headers / share strips / drawers.
+export const Inline: Story = {
+ args: { variant: 'inline' },
+};