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
183 changes: 183 additions & 0 deletions packages/shared/src/components/post/EndOfConversationShare.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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 {
activeDiscussionCommentThreshold,
EndOfConversationShare,
} from './EndOfConversationShare';
import type { Post } from '../../graphql/posts';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { useViewSize } from '../../hooks/useViewSize';
import { useToastNotification } from '../../hooks/useToastNotification';
import { LogEvent, Origin } from '../../lib/log';
import { ShareProvider } from '../../lib/share';

jest.mock('../../hooks/useViewSize', () => {
const actual = jest.requireActual('../../hooks/useViewSize');
return { __esModule: true, ...actual, useViewSize: jest.fn() };
});

jest.mock('../../hooks/useToastNotification', () => {
const actual = jest.requireActual('../../hooks/useToastNotification');
return { __esModule: true, ...actual, useToastNotification: jest.fn() };
});

const useViewSizeMock = useViewSize as jest.Mock;
const useToastNotificationMock = useToastNotification as jest.Mock;
const displayToast = jest.fn();
const writeText = jest.fn().mockResolvedValue(undefined);
const logEvent = jest.fn();

const permalink = 'https://app.daily.dev/posts/abc';

const createPost = (numComments: number): Post =>
({
id: 'abc',
title: 'Why your CI is slow',
permalink: 'https://daily.dev/posts/abc',
commentsPermalink: permalink,
numComments,
} as Post);

const enabledFlags = {
sharing_visibility: { defaultValue: true },
share_end_of_conversation: { defaultValue: true },
};

beforeEach(() => {
jest.clearAllMocks();
useViewSizeMock.mockReturnValue(true); // default: laptop
useToastNotificationMock.mockReturnValue({
displayToast,
dismissToast: jest.fn(),
});
Object.assign(navigator, { clipboard: { writeText }, maxTouchPoints: 0 });
delete (navigator as unknown as { share?: unknown }).share;
});

const renderComponent = (
numComments: number,
features: Record<string, { defaultValue: boolean }> = enabledFlags,
): RenderResult =>
render(
<TestBootProvider
client={new QueryClient()}
gb={new GrowthBook({ features })}
log={{ logEvent }}
>
<EndOfConversationShare post={createPost(numComments)} />
</TestBootProvider>,
);

const band = () => screen.queryByText('Enjoyed this discussion?');

describe('EndOfConversationShare threshold', () => {
it(`stays hidden at exactly ${activeDiscussionCommentThreshold} comments`, () => {
renderComponent(activeDiscussionCommentThreshold);

expect(band()).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Share this discussion'),
).not.toBeInTheDocument();
});

it('stays hidden with no comments at all', () => {
renderComponent(0);

expect(band()).not.toBeInTheDocument();
});

it(`renders one comment past the threshold`, () => {
renderComponent(activeDiscussionCommentThreshold + 1);

expect(band()).toBeInTheDocument();
expect(screen.getByText('Enjoyed this discussion?')).toBeInTheDocument();
});
});

describe('EndOfConversationShare flags', () => {
it('stays hidden when the master kill-switch is off', () => {
renderComponent(12, {
sharing_visibility: { defaultValue: false },
share_end_of_conversation: { defaultValue: true },
});

expect(band()).not.toBeInTheDocument();
});

it('stays hidden when its own experiment flag is off', () => {
renderComponent(12, {
sharing_visibility: { defaultValue: true },
share_end_of_conversation: { defaultValue: false },
});

expect(band()).not.toBeInTheDocument();
});

it('stays hidden with both flags at their defaults', () => {
renderComponent(12, {});

expect(band()).not.toBeInTheDocument();
});
});

describe('EndOfConversationShare sharing', () => {
it('copies the link and toasts on desktop', async () => {
renderComponent(12);

await act(async () => {
fireEvent.click(screen.getByLabelText('Share this discussion'));
});
await act(async () => {
fireEvent.click(await screen.findByText('Copy link'));
});

await waitFor(() => expect(writeText).toHaveBeenCalledWith(permalink));
expect(displayToast).toHaveBeenCalledWith(
'✅ Copied link to clipboard',
expect.anything(),
);
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.SharePost,
extra: JSON.stringify({
provider: ShareProvider.CopyLink,
origin: Origin.EndOfConversation,
}),
}),
);
});

it('opens the native share sheet on a single tap on mobile', async () => {
useViewSizeMock.mockReturnValue(false);
const share = jest.fn().mockResolvedValue(undefined);
// `shouldUseNativeShare` also requires a touch device.
Object.assign(navigator, { share, maxTouchPoints: 2 });

renderComponent(12);

await act(async () => {
fireEvent.click(screen.getByLabelText('Share this discussion'));
});

await waitFor(() => expect(share).toHaveBeenCalled());
expect(writeText).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.SharePost,
extra: JSON.stringify({
provider: ShareProvider.Native,
origin: Origin.EndOfConversation,
}),
}),
);
});
});
114 changes: 114 additions & 0 deletions packages/shared/src/components/post/EndOfConversationShare.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { Post } from '../../graphql/posts';
import { ShareActions } from '../share/ShareActions';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';
import { ButtonSize, ButtonVariant } from '../buttons/common';
import { useConditionalFeature } from '../../hooks/useConditionalFeature';
import { useSharingVisibility } from '../../hooks/useSharingVisibility';
import { featureShareEndOfConversation } from '../../lib/featureManagement';
import { useLogContext } from '../../contexts/LogContext';
import { postLogEvent } from '../../lib/feed';
import { LogEvent, Origin } from '../../lib/log';
import { ReferralCampaignKey } from '../../lib/referral';
import type { ShareProvider } from '../../lib/share';

/**
* A share prompt only earns its place at the end of a thread that has real
* back-and-forth — prompting on a quiet post trains people to ignore it. The
* band stays hidden until the post has MORE than this many comments, read from
* the typed `Post.numComments` field (total comments, replies included).
*/
export const activeDiscussionCommentThreshold = 3;

export const hasActiveDiscussion = (post: Post): boolean =>
(post.numComments ?? 0) > activeDiscussionCommentThreshold;

export interface EndOfConversationShareProps {
post: Post;
className?: string;
}

/**
* The band itself, including the activity threshold but without the feature
* gates, so Storybook can render both sides of the threshold without a
* GrowthBook instance.
*/
export const EndOfConversationShareBand = ({
post,
className,
}: EndOfConversationShareProps): ReactElement | null => {
const { logEvent } = useLogContext();

if (!hasActiveDiscussion(post)) {
return null;
}

const onShare = (provider: ShareProvider): void =>
logEvent(
postLogEvent(LogEvent.SharePost, post, {
extra: { provider, origin: Origin.EndOfConversation },
}),
);

return (
<aside
// Labelled by its own visible copy, so no aria-label here — a second
// "Share this discussion" label would shadow the share button's.
className={classNames(
'flex flex-col items-center gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4 text-center tablet:flex-row tablet:justify-between tablet:text-left',
className,
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<Typography bold type={TypographyType.Callout}>
Enjoyed this discussion?
</Typography>
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
Send it to someone who&apos;d have opinions.
</Typography>
</div>
<ShareActions
link={post.commentsPermalink}
text={post.title ?? post.sharedPost?.title ?? ''}
cid={ReferralCampaignKey.SharePost}
buttonVariant={ButtonVariant.Primary}
buttonSize={ButtonSize.Medium}
label="Share this discussion"
className="shrink-0"
onShare={onShare}
/>
</aside>
);
};

/**
* Encouraging share band rendered below the comment list of an active
* discussion. Gated by the initiative kill-switch plus its own experiment flag,
* and only evaluated once the post is past the activity threshold.
*/
export const EndOfConversationShare = ({
post,
className,
}: EndOfConversationShareProps): ReactElement | null => {
const isActive = hasActiveDiscussion(post);
const { isEnabled: isInitiativeEnabled } = useSharingVisibility(isActive);
const { value: isBandEnabled } = useConditionalFeature({
feature: featureShareEndOfConversation,
shouldEvaluate: isActive && isInitiativeEnabled,
});

if (!isInitiativeEnabled || !isBandEnabled) {
return null;
}

return <EndOfConversationShareBand post={post} className={className} />;
};
Loading
Loading