diff --git a/packages/shared/src/components/post/common/PostContentShare.spec.tsx b/packages/shared/src/components/post/common/PostContentShare.spec.tsx
new file mode 100644
index 00000000000..869b798fee2
--- /dev/null
+++ b/packages/shared/src/components/post/common/PostContentShare.spec.tsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import nock from 'nock';
+import { QueryClient } from '@tanstack/react-query';
+import { GrowthBook } from '@growthbook/growthbook-react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { PostContentShare } from './PostContentShare';
+import { TestBootProvider } from '../../../../__tests__/helpers/boot';
+import { mockGraphQL } from '../../../../__tests__/helpers/graphql';
+import loggedUser from '../../../../__tests__/fixture/loggedUser';
+import post from '../../../../__tests__/fixture/post';
+import { GET_SHORT_URL_QUERY } from '../../../graphql/urlShortener';
+import { getShortLinkProps } from '../../../hooks/utils/useGetShortUrl';
+import { ReferralCampaignKey } from '../../../lib/referral';
+import { generateQueryKey, RequestKey } from '../../../lib/query';
+import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification';
+import { shouldUseNativeShare } from '../../../lib/func';
+
+jest.mock('../../../lib/func', () => {
+ const actual = jest.requireActual('../../../lib/func');
+ return {
+ __esModule: true,
+ ...actual,
+ shouldUseNativeShare: jest.fn(),
+ };
+});
+
+const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock;
+const writeText = jest.fn().mockResolvedValue(undefined);
+const nativeShare = jest.fn().mockResolvedValue(undefined);
+const SHORT_LINK = 'https://dly.to/abc123';
+
+const { trackedUrl } = getShortLinkProps(
+ post.commentsPermalink,
+ ReferralCampaignKey.SharePost,
+ loggedUser,
+);
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ nock.cleanAll();
+ shouldUseNativeShareMock.mockReturnValue(false);
+ Object.assign(navigator, { clipboard: { writeText } });
+});
+
+const createClient = (): QueryClient => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ client.setQueryData(
+ generateQueryKey(RequestKey.PostActions, { id: post.id }),
+ { interaction: 'upvote', previousInteraction: 'none' },
+ );
+
+ return client;
+};
+
+const renderComponent = (enabled: boolean, client = createClient()): void => {
+ const gb = new GrowthBook();
+ gb.setFeatures({
+ sharing_visibility: { defaultValue: enabled },
+ share_upvote_prompt: { defaultValue: enabled },
+ });
+
+ mockGraphQL({
+ request: { query: GET_SHORT_URL_QUERY, variables: { url: trackedUrl } },
+ result: { data: { getShortUrl: SHORT_LINK } },
+ });
+
+ render(
+
+
+ ,
+ );
+};
+
+describe('PostContentShare with the flag off', () => {
+ it('renders the existing widget unchanged', async () => {
+ renderComponent(false);
+
+ expect(
+ await screen.findByText('Should anyone else see this post?'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText('Good call. Now pass it on.'),
+ ).not.toBeInTheDocument();
+ expect(screen.getByDisplayValue(SHORT_LINK)).toBeInTheDocument();
+ });
+});
+
+describe('PostContentShare with the flag on', () => {
+ it('renders the redesigned prompt on top of the resolved short URL', async () => {
+ renderComponent(true);
+
+ expect(
+ await screen.findByText('Good call. Now pass it on.'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText('Should anyone else see this post?'),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('Copy link')).toBeInTheDocument();
+ expect(screen.getByText('WhatsApp')).toBeInTheDocument();
+ });
+
+ it('copies the short link to the clipboard and toasts', async () => {
+ const client = createClient();
+ renderComponent(true, client);
+
+ await screen.findByText('Good call. Now pass it on.');
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Copy link'));
+ });
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith(SHORT_LINK));
+ expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({
+ message: '✅ Copied link to clipboard',
+ });
+ });
+
+ it('opens the native share sheet on mobile', async () => {
+ shouldUseNativeShareMock.mockReturnValue(true);
+ Object.assign(navigator, { share: nativeShare });
+
+ renderComponent(true);
+
+ await screen.findByText('Good call. Now pass it on.');
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Share via...'));
+ });
+
+ await waitFor(() => expect(nativeShare).toHaveBeenCalled());
+ expect(nativeShare.mock.calls[0][0].text).toContain(SHORT_LINK);
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ it('dismisses the prompt from the close button', async () => {
+ renderComponent(true);
+
+ await screen.findByText('Good call. Now pass it on.');
+
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Dismiss share prompt'));
+ });
+
+ await waitFor(() =>
+ expect(
+ screen.queryByText('Good call. Now pass it on.'),
+ ).not.toBeInTheDocument(),
+ );
+ });
+});
diff --git a/packages/shared/src/components/post/common/PostContentShare.tsx b/packages/shared/src/components/post/common/PostContentShare.tsx
index f53b756c3ec..a584992ff5e 100644
--- a/packages/shared/src/components/post/common/PostContentShare.tsx
+++ b/packages/shared/src/components/post/common/PostContentShare.tsx
@@ -9,6 +9,20 @@ import { ReferralCampaignKey, useGetShortUrl } from '../../../hooks';
import { PostContentWidget } from './PostContentWidget';
import { useActiveFeedContext } from '../../../contexts';
import { postLogEvent } from '../../../lib/feed';
+import { ShareActions } from '../../share/ShareActions';
+import { useSharingVisibility } from '../../../hooks/useSharingVisibility';
+import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
+import { featureShareUpvotePrompt } from '../../../lib/featureManagement';
+import { useLogContext } from '../../../contexts/LogContext';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../typography/Typography';
+import { UpvoteIcon } from '../../icons';
+import { IconSize } from '../../Icon';
+import CloseButton from '../../CloseButton';
+import { ButtonSize } from '../../buttons/Button';
interface PostContentShareProps {
post: Post;
@@ -19,35 +33,84 @@ export function PostContentShare({
}: PostContentShareProps): ReactElement | null {
const { onInteract, interaction } = usePostActions({ post });
const { logOpts } = useActiveFeedContext();
+ const { logEvent } = useLogContext();
+ const isUpvoted = interaction === 'upvote';
const { isLoading, shareLink } = useGetShortUrl({
query: {
url: post.commentsPermalink,
cid: ReferralCampaignKey.SharePost,
- enabled: interaction === 'upvote',
+ enabled: isUpvoted,
},
});
+ const { isEnabled: isSharingVisible } = useSharingVisibility(isUpvoted);
+ const { value: isPromptEnabled } = useConditionalFeature({
+ feature: featureShareUpvotePrompt,
+ shouldEvaluate: isSharingVisible,
+ });
- if (interaction !== 'upvote' || isLoading) {
+ if (!isUpvoted || isLoading) {
return null;
}
+ const shareText = post.title || 'I found this on daily.dev';
+ const buildLogEvent = (provider: ShareProvider) =>
+ postLogEvent(LogEvent.SharePost, post, {
+ extra: { provider, origin: Origin.PostContent },
+ ...(logOpts && logOpts),
+ });
+
+ if (!isSharingVisible || !isPromptEnabled) {
+ return (
+
+ onInteract('none')}
+ logProps={buildLogEvent(ShareProvider.CopyLink)}
+ />
+
+ );
+ }
+
+ // The prompt fires right after an upvote — peak intent — so it stays mounted
+ // after a share instead of self-dismissing, letting the user hit more than
+ // one destination. Dismissal moves to the explicit close button.
return (
-
-
+
+
+
+
+
+
+ Good call. Now pass it on.
+
+
+ Send it to the one person who’ll actually read it. That’s how
+ millions of developers find the good stuff on daily.dev.
+
+
+ onInteract('none')}
+ />
+
+ onInteract('none')}
- logProps={postLogEvent(LogEvent.SharePost, post, {
- extra: {
- provider: ShareProvider.CopyLink,
- origin: Origin.PostContent,
- },
- ...(logOpts && logOpts),
- })}
+ text={shareText}
+ emailTitle={shareText}
+ className="justify-center laptop:justify-start"
+ onShare={(provider) => logEvent(buildLogEvent(provider))}
/>
-
+
);
}
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 97b38cbdd2b..d7781d51c47 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -308,3 +308,13 @@ 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);
+
+// Redesigns the post-upvote share prompt on the post page (`PostContentShare`)
+// into a prominent, inviting card with the full share row instead of the plain
+// "Should anyone else see this post?" copy-link widget. Fires at a peak-intent
+// moment, so it ramps on its own flag alongside the `sharing_visibility` master
+// gate. Keep the default `false` (control = the existing widget).
+export const featureShareUpvotePrompt = new Feature(
+ 'share_upvote_prompt',
+ false,
+);
diff --git a/packages/storybook/stories/components/PostContentShare.stories.tsx b/packages/storybook/stories/components/PostContentShare.stories.tsx
new file mode 100644
index 00000000000..37ee62f80dd
--- /dev/null
+++ b/packages/storybook/stories/components/PostContentShare.stories.tsx
@@ -0,0 +1,192 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
+import { PostContentShare } from '@dailydotdev/shared/src/components/post/common/PostContentShare';
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts';
+import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext';
+import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
+import {
+ FeaturesReadyContext,
+ GrowthBookProvider,
+} from '@dailydotdev/shared/src/components/GrowthBookProvider';
+import { BootApp } from '@dailydotdev/shared/src/lib/boot';
+import {
+ generateQueryKey,
+ RequestKey,
+} from '@dailydotdev/shared/src/lib/query';
+import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl';
+import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral';
+import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
+
+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;
+
+const post = {
+ id: 'post-1',
+ title: 'The pragmatic guide to shipping fast without breaking prod',
+ permalink: 'https://api.daily.dev/r/post-1',
+ commentsPermalink: 'https://daily.dev/posts/post-1',
+ image:
+ 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/placeholder',
+ createdAt: '2024-01-15T10:30:00.000Z',
+ numUpvotes: 42,
+ numComments: 12,
+ type: PostType.Article,
+ source: {
+ id: 'tds',
+ handle: 'tds',
+ name: 'Towards Data Science',
+ permalink: 'https://app.daily.dev/sources/tds',
+ image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/tds',
+ type: 'machine',
+ active: true,
+ },
+ userState: { vote: UserVote.Up, flags: { feedbackDismiss: false } },
+} as unknown as Post;
+
+const SHORT_LINK = 'https://dly.to/abc123';
+
+// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue`
+// coerces every falsy default to the truthy string `'control'`, so a flag can't
+// be evaluated as `false` here. Flag-off is therefore simulated by holding the
+// features context as "not ready", which is the exact path
+// `useConditionalFeature` takes to fall back to the (false) default value.
+const withProviders =
+ (enabled: boolean) =>
+ (Story: React.ComponentType): React.ReactElement => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+
+ // The prompt only renders after an upvote, so seed the post-actions state.
+ queryClient.setQueryData(
+ generateQueryKey(RequestKey.PostActions, { id: post.id }),
+ { interaction: 'upvote', previousInteraction: 'none' },
+ );
+
+ // Seed the resolved short URL so nothing hits the network.
+ const { queryKey } = getShortLinkProps(
+ post.commentsPermalink,
+ ReferralCampaignKey.SharePost,
+ mockUser,
+ );
+ queryClient.setQueryData(queryKey, SHORT_LINK);
+
+ const LogContext = getLogContextStatic();
+
+ return (
+
+
+
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ feature.defaultValue as any,
+ }}
+ >
+ false,
+ }}
+ >
+
+
+
+
+
+
+
+
+ );
+ };
+
+const meta: Meta = {
+ title: 'Components/Share/PostContentShare',
+ component: PostContentShare,
+ args: { post },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Post-upvote share prompt. Control is the plain "Should anyone else see this post?" copy-link widget; the `share_upvote_prompt` variant (also gated by the `sharing_visibility` master flag) turns it into a prominent card built on the shared `ShareActions` row.',
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// Flag on — the redesigned prompt at the peak-intent moment after an upvote.
+export const Redesigned: Story = {
+ decorators: [withProviders(true)],
+};
+
+// Flag off — must render exactly what ships today.
+export const Control: Story = {
+ decorators: [withProviders(false)],
+};
+
+// Narrow viewport: the share row centres and wraps onto two rows. The native
+// "Share via…" chip only appears where `navigator.share` exists.
+export const RedesignedMobile: Story = {
+ decorators: [withProviders(true)],
+ globals: { viewport: { value: 'mobile1' } },
+};
+
+// Copying state: the copy chip flips to "Copied!" for a beat. The clipboard is
+// stubbed because the Storybook iframe isn't allowed to write to the real one.
+export const RedesignedCopying: Story = {
+ decorators: [withProviders(true)],
+ play: async ({ canvasElement }) => {
+ Object.defineProperty(globalThis.navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: async () => undefined },
+ });
+
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByTestId('social-share-Copy link'));
+ await waitFor(() => expect(canvas.getByText('Copied!')).toBeInTheDocument());
+ },
+};