Skip to content
96 changes: 51 additions & 45 deletions packages/shared/src/components/post/EndOfConversationShare.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import {
activeDiscussionCommentThreshold,
EndOfConversationShare,
Expand Down Expand Up @@ -47,11 +47,6 @@ const createPost = (numComments: number): Post =>
numComments,
} as Post);

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

beforeEach(() => {
jest.clearAllMocks();
useViewSizeMock.mockReturnValue(true); // default: laptop
Expand All @@ -63,16 +58,9 @@ beforeEach(() => {
delete (navigator as unknown as { share?: unknown }).share;
});

const renderComponent = (
numComments: number,
features: Record<string, { defaultValue: boolean }> = enabledFlags,
): RenderResult =>
const renderComponent = (numComments: number): RenderResult =>
render(
<TestBootProvider
client={new QueryClient()}
gb={new GrowthBook({ features })}
log={{ logEvent }}
>
<TestBootProvider client={new QueryClient()} log={{ logEvent }}>
<EndOfConversationShare post={createPost(numComments)} />
</TestBootProvider>,
);
Expand All @@ -85,7 +73,7 @@ describe('EndOfConversationShare threshold', () => {

expect(band()).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Share this discussion'),
screen.queryByRole('button', { name: 'Copy link' }),
).not.toBeInTheDocument();
});

Expand All @@ -103,48 +91,62 @@ describe('EndOfConversationShare threshold', () => {
});
});

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 },
});
describe('EndOfConversationShare sharing', () => {
it('copies the link from the main half of the split button on desktop', async () => {
renderComponent(12);

expect(band()).not.toBeInTheDocument();
});
const copyButton = screen.getByRole('button', { name: 'Copy link' });
// Both glyphs are always mounted so they can cross-fade; only the check's
// opacity says which one is showing.
const check = () => copyButton.querySelector('.text-status-success');
expect(check()).toHaveClass('opacity-0');

it('stays hidden when its own experiment flag is off', () => {
renderComponent(12, {
sharing_visibility: { defaultValue: true },
share_end_of_conversation: { defaultValue: false },
await act(async () => {
fireEvent.click(copyButton);
});

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

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

expect(band()).not.toBeInTheDocument();
// The copy glyph cross-fades to a green check for the confirmation window.
await waitFor(() => expect(check()).not.toHaveClass('opacity-0'));
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,
}),
}),
);
});
});

describe('EndOfConversationShare sharing', () => {
it('copies the link and toasts on desktop', async () => {
it('opens the full share list from the chevron half, without copying', async () => {
renderComponent(12);

// Radix dropdowns open on pointerdown (which jsdom cannot synthesise) or on
// Enter, so the keyboard path is what a test can drive.
await act(async () => {
fireEvent.click(screen.getByLabelText('Share this discussion'));
fireEvent.keyDown(screen.getByLabelText('More share options'), {
key: 'Enter',
});
});

const popover = await screen.findByRole('menu');
expect(within(popover).getByTestId('social-share-X')).toBeInTheDocument();
expect(
within(popover).getByTestId('social-share-WhatsApp'),
).toBeInTheDocument();
expect(writeText).not.toHaveBeenCalled();

// The copy action inside the list still works and logs the same origin.
await act(async () => {
fireEvent.click(await screen.findByText('Copy link'));
fireEvent.click(within(popover).getByTestId('social-share-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,
Expand All @@ -164,8 +166,12 @@ describe('EndOfConversationShare sharing', () => {

renderComponent(12);

// Mobile keeps a single button — no chevron half, no popover.
expect(
screen.queryByLabelText('More share options'),
).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByLabelText('Share this discussion'));
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
});

await waitFor(() => expect(share).toHaveBeenCalled());
Expand Down
59 changes: 24 additions & 35 deletions packages/shared/src/components/post/EndOfConversationShare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import {
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';
Expand All @@ -26,21 +23,29 @@ import type { ShareProvider } from '../../lib/share';
*/
export const activeDiscussionCommentThreshold = 3;

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

export type EndOfConversationShareVariant = 'card' | 'flat';

export interface EndOfConversationShareProps {
post: Post;
/**
* `flat` (default) drops the fill and leans on a single hairline rule to
* separate the strip from the comments above it; `card` is the heavier
* self-contained surface.
*/
variant?: EndOfConversationShareVariant;
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.
* Encouraging share band rendered below the comment list of an active
* discussion. Ships to everyone — the comment threshold is the only condition.
*/
export const EndOfConversationShareBand = ({
export const EndOfConversationShare = ({
post,
variant = 'flat',
className,
}: EndOfConversationShareProps): ReactElement | null => {
const { logEvent } = useLogContext();
Expand All @@ -59,9 +64,13 @@ export const EndOfConversationShareBand = ({
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.
// label on the landmark 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',
'flex flex-col items-center gap-3 text-center tablet:flex-row tablet:justify-between tablet:text-left',
variant === 'card' &&
'rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4',
variant === 'flat' &&
'border-t border-border-subtlest-tertiary pb-4 pt-6',
className,
)}
>
Expand All @@ -80,35 +89,15 @@ export const EndOfConversationShareBand = ({
link={post.commentsPermalink}
text={post.title ?? post.sharedPost?.title ?? ''}
cid={ReferralCampaignKey.SharePost}
variant="split"
buttonVariant={ButtonVariant.Primary}
buttonSize={ButtonSize.Medium}
label="Share this discussion"
buttonSize={ButtonSize.Small}
label="Copy link"
triggerText="Copy link"
dropdownLabel="More share options"
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} />;
};
32 changes: 5 additions & 27 deletions packages/shared/src/components/post/PostComments.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { PostComments } from './PostComments';
import type { Post } from '../../graphql/posts';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
Expand Down Expand Up @@ -39,28 +38,17 @@ const createPost = (numComments: number): Post =>
numComments,
} as Post);

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

beforeEach(() => {
jest.clearAllMocks();
});

const renderComponent = (
numComments: number,
features: Record<string, { defaultValue: boolean }> = {},
): RenderResult => {
const renderComponent = (numComments: number): RenderResult => {
mockRequestMethod.mockReturnValue(() =>
Promise.resolve(buildComments(numComments)),
);

return render(
<TestBootProvider
client={new QueryClient()}
gb={new GrowthBook({ features })}
>
<TestBootProvider client={new QueryClient()}>
<PostComments
post={createPost(numComments)}
origin={Origin.ArticlePage}
Expand All @@ -71,7 +59,7 @@ const renderComponent = (

describe('PostComments end-of-conversation share band', () => {
it('appends the band after the last comment on an active discussion', async () => {
renderComponent(6, enabledFlags);
renderComponent(6);

const comments = await screen.findAllByTestId('main-comment');
await waitFor(() => expect(comments).toHaveLength(6));
Expand All @@ -82,18 +70,8 @@ describe('PostComments end-of-conversation share band', () => {
);
});

it('leaves the comment list untouched when the flags are off', async () => {
renderComponent(6);

const comments = await screen.findAllByTestId('main-comment');
await waitFor(() => expect(comments).toHaveLength(6));

expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
expect(comments[5].nextElementSibling).toBeNull();
});

it('keeps the band hidden on a quiet discussion even with the flags on', async () => {
renderComponent(2, enabledFlags);
it('keeps the band hidden on a quiet discussion', async () => {
renderComponent(2);

const comments = await screen.findAllByTestId('main-comment');
await waitFor(() => expect(comments).toHaveLength(2));
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/components/post/PostComments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,9 @@ export function PostComments({
{!hideEndOfConversationShare && (
<EndOfConversationShare
post={post}
// Cancels the list's mobile full-bleed negative margin so the band
// keeps its rounded edges inside the page gutter.
// Cancels the list's mobile full-bleed negative margin so the band's
// separator rule lines up with the comment content above it rather
// than running edge to edge.
className={isModalThread ? undefined : 'mx-4 mobileL:mx-0'}
/>
)}
Expand Down
47 changes: 47 additions & 0 deletions packages/shared/src/components/share/CopyStateIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import { CopyIcon, VIcon } from '../icons';
import type { IconProps } from '../Icon';

/**
* easeOutExpo — the curve the design-system dropdown animates on. It
* decelerates into the target with no overshoot, which is what keeps a swap
* from reading as a wobble.
*/
export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';

/**
* A copy is a rare, deliberate moment, so the confirmation earns real motion.
* Both glyphs share one grid cell so the label never shifts mid-swap, and the
* transition collapses to an instant swap under `prefers-reduced-motion`.
*/
export const CopyStateIcon = ({
copied,
className,
...props
}: IconProps & { copied: boolean }): ReactElement => {
const layer = classNames(
className,
'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none',
EASE_OUT_EXPO,
);

return (
<span className="inline-grid">
<CopyIcon
{...props}
className={classNames(layer, copied && 'scale-50 opacity-0 blur-[2px]')}
/>
<VIcon
{...props}
secondary
className={classNames(
layer,
'text-status-success',
!copied && 'scale-50 opacity-0 blur-[2px]',
)}
/>
</span>
);
};
Loading
Loading