From 91b897feedd7c460ba7508a2ab5133ff65b1f3f3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:05:14 +0300 Subject: [PATCH] feat(share): end-of-conversation share band Adds an encouraging share band below the comment list of posts with an active discussion (more than 3 comments, read from `Post.numComments`). It reuses the Phase-0 `ShareActions` primitive, so mobile taps straight into the native share sheet and desktop opens the share popover. - add EndOfConversationShare (threshold + flag gates) with Storybook stories covering below/at/above threshold and the mobile layout - render it below the comment list in PostComments (classic path) and at the end of PostDiscussionPanel (post-redesign path) - add the share_end_of_conversation flag (default false) on top of the sharing_visibility kill-switch, plus an EndOfConversation Origin - log LogEvent.SharePost with the provider and the new origin Co-Authored-By: Claude Opus 4.8 --- .../post/EndOfConversationShare.spec.tsx | 183 ++++++++++++++++++ .../post/EndOfConversationShare.tsx | 114 +++++++++++ .../src/components/post/PostComments.spec.tsx | 103 ++++++++++ .../src/components/post/PostComments.tsx | 16 ++ .../post/focus/PostDiscussionPanel.tsx | 3 + packages/shared/src/lib/featureManagement.ts | 9 + packages/shared/src/lib/log.ts | 1 + .../EndOfConversationShare.stories.tsx | 137 +++++++++++++ 8 files changed, 566 insertions(+) create mode 100644 packages/shared/src/components/post/EndOfConversationShare.spec.tsx create mode 100644 packages/shared/src/components/post/EndOfConversationShare.tsx create mode 100644 packages/shared/src/components/post/PostComments.spec.tsx create mode 100644 packages/storybook/stories/components/EndOfConversationShare.stories.tsx diff --git a/packages/shared/src/components/post/EndOfConversationShare.spec.tsx b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx new file mode 100644 index 00000000000..c4220c49dea --- /dev/null +++ b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx @@ -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 = enabledFlags, +): RenderResult => + render( + + + , + ); + +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, + }), + }), + ); + }); +}); diff --git a/packages/shared/src/components/post/EndOfConversationShare.tsx b/packages/shared/src/components/post/EndOfConversationShare.tsx new file mode 100644 index 00000000000..4b148f7a1d2 --- /dev/null +++ b/packages/shared/src/components/post/EndOfConversationShare.tsx @@ -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 ( + + ); +}; + +/** + * 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 ; +}; diff --git a/packages/shared/src/components/post/PostComments.spec.tsx b/packages/shared/src/components/post/PostComments.spec.tsx new file mode 100644 index 00000000000..06235481f88 --- /dev/null +++ b/packages/shared/src/components/post/PostComments.spec.tsx @@ -0,0 +1,103 @@ +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'; +import { Origin } from '../../lib/log'; + +const mockRequestMethod = jest.fn(); + +jest.mock('../comments/MainComment', () => ({ + __esModule: true, + default: ({ comment }: { comment: { id: string } }) => ( +
{comment.id}
+ ), +})); + +jest.mock('../../hooks/useRequestProtocol', () => ({ + useRequestProtocol: () => ({ requestMethod: mockRequestMethod() }), +})); + +const buildComments = (count: number) => ({ + postComments: { + pageInfo: { hasNextPage: false, endCursor: null }, + edges: Array.from({ length: count }, (_, index) => ({ + node: { id: `c${index}` }, + })), + }, +}); + +const createPost = (numComments: number): Post => + ({ + id: 'abc', + title: 'Why your CI is slow', + permalink: 'https://daily.dev/posts/abc', + commentsPermalink: 'https://app.daily.dev/posts/abc', + numComments, + } as Post); + +const enabledFlags = { + sharing_visibility: { defaultValue: true }, + share_end_of_conversation: { defaultValue: true }, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const renderComponent = ( + numComments: number, + features: Record = {}, +): RenderResult => { + mockRequestMethod.mockReturnValue(() => + Promise.resolve(buildComments(numComments)), + ); + + return render( + + + , + ); +}; + +describe('PostComments end-of-conversation share band', () => { + it('appends the band after the last comment on an active discussion', async () => { + renderComponent(6, enabledFlags); + + const comments = await screen.findAllByTestId('main-comment'); + await waitFor(() => expect(comments).toHaveLength(6)); + + expect(screen.getByText('Enjoyed this discussion?')).toBeInTheDocument(); + expect(comments[5].nextElementSibling).toBe( + screen.getByRole('complementary'), + ); + }); + + 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); + + const comments = await screen.findAllByTestId('main-comment'); + await waitFor(() => expect(comments).toHaveLength(2)); + + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index 98a94146c16..42137bde596 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -24,6 +24,7 @@ import { useCommentContentPreferenceMutationSubscription } from './useCommentCon import { generateCommentsQueryKey } from '../../lib/query'; import { CharmEmptyState } from '../charm/CharmEmptyState'; import { cloudinaryCharmNoComments } from '../../lib/image'; +import { EndOfConversationShare } from './EndOfConversationShare'; const threadCommentOrigins = new Set([ Origin.ArticleModal, @@ -52,6 +53,12 @@ interface PostCommentsProps { * extra gap above the first item. */ removeTopSpacing?: boolean; + /** + * Skips the end-of-conversation share band. Set by parents that place the + * band themselves so it lands at the true end of their surface (the redesign + * discussion panel renders it after its meta bar). + */ + hideEndOfConversationShare?: boolean; } const noopShare = (): void => {}; @@ -70,6 +77,7 @@ export function PostComments({ className = {}, onCommented, removeTopSpacing = false, + hideEndOfConversationShare = false, }: PostCommentsProps): ReactElement { const { id } = post; const container = useRef(null); @@ -165,6 +173,14 @@ export function PostComments({ lazy={!commentHash && index >= lazyCommentThreshold} /> ))} + {!hideEndOfConversationShare && ( + + )} ); } diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx index 1df8d717ac9..cde63f7a780 100644 --- a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -24,6 +24,7 @@ import { ClickableText } from '../../buttons/ClickableText'; import { IconSize } from '../../Icon'; import { TimeSortIcon } from '../../icons/Sort/Time'; import { SortCommentsBy } from '../../../graphql/comments'; +import { EndOfConversationShare } from '../EndOfConversationShare'; import { DiscussionMetaBar } from './DiscussionMetaBar'; import { DiscussionShareRow } from './DiscussionShareRow'; @@ -201,6 +202,7 @@ export const PostDiscussionPanel = ({ onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} modalParentSelector={resolveModalParent} removeTopSpacing + hideEndOfConversationShare /> {showMetaBar && ( @@ -208,6 +210,7 @@ export const PostDiscussionPanel = ({ )} + ); }; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..f585086aff7 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,12 @@ 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); + +// Renders an encouraging share band below the comment list once a post has an +// active discussion (more than 3 comments). Part of the sharing-visibility +// initiative, so it also sits behind the `sharing_visibility` kill-switch. +// Keep the default `false` — GrowthBook ramps it. +export const featureShareEndOfConversation = new Feature( + 'share_end_of_conversation', + false, +); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa61..6686722d081 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,7 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + EndOfConversation = 'end of conversation', } export enum LogEvent { diff --git a/packages/storybook/stories/components/EndOfConversationShare.stories.tsx b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx new file mode 100644 index 00000000000..525fa35437e --- /dev/null +++ b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx @@ -0,0 +1,137 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + activeDiscussionCommentThreshold, + EndOfConversationShareBand, +} from '@dailydotdev/shared/src/components/post/EndOfConversationShare'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import { fn } from 'storybook/test'; + +const basePost = { + id: 'p1', + title: 'Why your CI is slow and what actually fixes it', + permalink: 'https://daily.dev/posts/p1', + commentsPermalink: 'https://app.daily.dev/posts/p1', + numComments: 12, +} as unknown as Post; + +const withPost = (numComments: number): Post => + ({ ...basePost, numComments } as Post); + +const meta: Meta = { + title: 'Components/Share/EndOfConversationShare', + component: EndOfConversationShareBand, + args: { post: basePost }, + parameters: { + docs: { + description: { + component: `Share band rendered below the comment list. It only appears once a post +has an active discussion — more than ${activeDiscussionCommentThreshold} comments, read from +\`Post.numComments\`. In the app it is additionally gated by the \`sharing_visibility\` +kill-switch and the \`share_end_of_conversation\` experiment flag.`, + }, + }, + }, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Mock the short-URL resolution so copy/social actions don't hit network. + queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); + + const LogContext = getLogContextStatic(); + + 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; + + return ( + + + false, + }} + > +
+ +
+
+
+
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +// Active discussion: the band renders below the comment list. +export const ActiveDiscussion: Story = { + args: { post: withPost(12) }, +}; + +// Exactly at the threshold — still hidden, the band needs MORE than 3 comments. +export const AtThreshold: Story = { + args: { post: withPost(activeDiscussionCommentThreshold) }, +}; + +// One comment past the threshold: the first state where the band shows. +export const JustAboveThreshold: Story = { + args: { post: withPost(activeDiscussionCommentThreshold + 1) }, +}; + +// Quiet post: nothing renders, so we never prompt on an empty discussion. +export const NoComments: Story = { + args: { post: withPost(0) }, +}; + +// Narrow the Storybook viewport to see the mobile layout: the band stacks and +// a single tap on the share button opens the native share sheet (falling back +// to copy when the device has no share target). +export const Mobile: Story = { + args: { post: withPost(12) }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +};