Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/shared/src/components/post/common/PostContentShare.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TestBootProvider client={client} auth={{ user: loggedUser }} gb={gb}>
<PostContentShare post={post} />
</TestBootProvider>,
);
};

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(),
);
});
});
97 changes: 80 additions & 17 deletions packages/shared/src/components/post/common/PostContentShare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<PostContentWidget
className="mt-6"
title="Should anyone else see this post?"
>
<InviteLinkInput
className={{ container: 'w-full flex-1' }}
link={shareLink}
onCopy={() => onInteract('none')}
logProps={buildLogEvent(ShareProvider.CopyLink)}
/>
</PostContentWidget>
);
}

// 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 (
<PostContentWidget
className="mt-6"
title="Should anyone else see this post?"
>
<InviteLinkInput
className={{ container: 'w-full flex-1' }}
<section className="mt-6 flex flex-col gap-4 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4">
<div className="flex flex-row items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-10 bg-brand-float text-brand-default">
<UpvoteIcon secondary size={IconSize.Small} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Typography type={TypographyType.Body} bold>
Good call. Now pass it on.
</Typography>
<Typography
type={TypographyType.Callout}
color={TypographyColor.Tertiary}
>
Send it to the one person who’ll actually read it. That’s how
millions of developers find the good stuff on daily.dev.
</Typography>
</div>
<CloseButton
type="button"
size={ButtonSize.Small}
aria-label="Dismiss share prompt"
onClick={() => onInteract('none')}
/>
</div>
<ShareActions
variant="inline"
link={shareLink}
onCopy={() => 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))}
/>
</PostContentWidget>
</section>
);
}
10 changes: 10 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Loading
Loading