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()); + }, +};