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
140 changes: 95 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 @@ -156,6 +158,50 @@ describe('EndOfConversationShare sharing', () => {
);
});

it.each([
['X', ShareProvider.Twitter],
['WhatsApp', ShareProvider.WhatsApp],
['LinkedIn', ShareProvider.LinkedIn],
])(
'logs a %s share from the dropdown, opening a window without copying',
async (label, provider) => {
// A social tile opens the network's share URL in a new tab; stub it so the
// click is inert and assertable.
const open = jest.spyOn(window, 'open').mockReturnValue(null);
renderComponent(12);

await act(async () => {
fireEvent.keyDown(screen.getByLabelText('More share options'), {
key: 'Enter',
});
});
const popover = await screen.findByRole('menu');

await act(async () => {
fireEvent.click(within(popover).getByTestId(`social-share-${label}`));
});

// Same event and origin as every other share on this surface, tagged with
// the tile's own provider — this is the row that feeds the per-network
// breakdown in analytics.
expect(logEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.SharePost,
extra: JSON.stringify({
provider,
origin: Origin.EndOfConversation,
}),
}),
);
await waitFor(() => expect(open).toHaveBeenCalled());
// A social share is not a copy — the clipboard must stay untouched so the
// two never conflate in the funnel.
expect(writeText).not.toHaveBeenCalled();

open.mockRestore();
},
);

it('opens the native share sheet on a single tap on mobile', async () => {
useViewSizeMock.mockReturnValue(false);
const share = jest.fn().mockResolvedValue(undefined);
Expand All @@ -164,8 +210,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
91 changes: 26 additions & 65 deletions packages/shared/src/components/post/EndOfConversationShare.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,7 @@ 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 { ShareBand } from '../share/ShareBand';
import { useLogContext } from '../../contexts/LogContext';
import { postLogEvent } from '../../lib/feed';
import { LogEvent, Origin } from '../../lib/log';
Expand All @@ -26,21 +17,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 @@ -57,58 +56,20 @@ 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.
<ShareBand
title="Enjoyed this discussion?"
description="Send it to someone who’d have opinions."
link={post.commentsPermalink}
text={post.title ?? post.sharedPost?.title ?? ''}
cid={ReferralCampaignKey.SharePost}
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',
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,
)}
>
<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>
onShare={onShare}
/>
);
};

/**
* 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} />;
};
4 changes: 4 additions & 0 deletions packages/shared/src/components/post/MobilePostFloatingBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button';
import { IconSize } from '../Icon';
import InteractionCounter from '../InteractionCounter';
import { useVotePost } from '../../hooks/vote/useVotePost';
import { useRecordUpvoteInteraction } from '../../hooks/post/usePostActions';
import { useBookmarkPost } from '../../hooks/useBookmarkPost';
import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel';
import { useCopyPostLink } from '../../hooks/useCopyPostLink';
Expand Down Expand Up @@ -62,6 +63,7 @@ function MobilePostFloatingBarV1({
const origin = LogOrigin.ArticlePage;
const { onClose, onShowPanel } = useBlockPostPanel(post);
const { toggleUpvote, toggleDownvote } = useVotePost();
const recordUpvoteInteraction = useRecordUpvoteInteraction({ post });
const { toggleBookmark } = useBookmarkPost();

// Match the desktop `PostActions` copy flow: fetch the short URL imperatively
Expand All @@ -80,6 +82,8 @@ function MobilePostFloatingBarV1({
onClose(true);
}

recordUpvoteInteraction(isUpvoteActive);

await toggleUpvote({ payload: post, origin });
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CardActionBar } from '../buttons/CardActionBar';
import { BookmarkButton } from '../buttons/BookmarkButton.v2';
import { ButtonColor } from '../buttons/ButtonV2';
import { useVotePost } from '../../hooks/vote/useVotePost';
import { useRecordUpvoteInteraction } from '../../hooks/post/usePostActions';
import { useBookmarkPost } from '../../hooks/useBookmarkPost';
import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel';
import { useCopyPostLink } from '../../hooks/useCopyPostLink';
Expand Down Expand Up @@ -45,6 +46,7 @@ export function MobilePostFloatingBar({
const origin = LogOrigin.ArticlePage;
const { onClose, onShowPanel } = useBlockPostPanel(post);
const { toggleUpvote, toggleDownvote } = useVotePost();
const recordUpvoteInteraction = useRecordUpvoteInteraction({ post });
const { toggleBookmark } = useBookmarkPost();

const { getShortUrl } = useGetShortUrl();
Expand All @@ -61,6 +63,8 @@ export function MobilePostFloatingBar({
onClose(true);
}

recordUpvoteInteraction(isUpvoteActive);

await toggleUpvote({ payload: post, origin });
};

Expand Down
Loading
Loading