diff --git a/packages/shared/src/components/post/EndOfConversationShare.spec.tsx b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx index c4220c49dea..ce84ede59c2 100644 --- a/packages/shared/src/components/post/EndOfConversationShare.spec.tsx +++ b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx @@ -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, @@ -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 @@ -63,16 +58,9 @@ beforeEach(() => { delete (navigator as unknown as { share?: unknown }).share; }); -const renderComponent = ( - numComments: number, - features: Record = enabledFlags, -): RenderResult => +const renderComponent = (numComments: number): RenderResult => render( - + , ); @@ -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(); }); @@ -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, @@ -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); @@ -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()); diff --git a/packages/shared/src/components/post/EndOfConversationShare.tsx b/packages/shared/src/components/post/EndOfConversationShare.tsx index 4b148f7a1d2..e05e09a7c10 100644 --- a/packages/shared/src/components/post/EndOfConversationShare.tsx +++ b/packages/shared/src/components/post/EndOfConversationShare.tsx @@ -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'; @@ -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(); @@ -57,58 +56,20 @@ export const EndOfConversationShareBand = ({ ); return ( - + 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 ; -}; diff --git a/packages/shared/src/components/post/MobilePostFloatingBar.tsx b/packages/shared/src/components/post/MobilePostFloatingBar.tsx index 4f595daff2a..b8953f6c34e 100644 --- a/packages/shared/src/components/post/MobilePostFloatingBar.tsx +++ b/packages/shared/src/components/post/MobilePostFloatingBar.tsx @@ -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'; @@ -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 @@ -80,6 +82,8 @@ function MobilePostFloatingBarV1({ onClose(true); } + recordUpvoteInteraction(isUpvoteActive); + await toggleUpvote({ payload: post, origin }); }; diff --git a/packages/shared/src/components/post/MobilePostFloatingBar.v2.tsx b/packages/shared/src/components/post/MobilePostFloatingBar.v2.tsx index 42ef412c93b..e6e8119999a 100644 --- a/packages/shared/src/components/post/MobilePostFloatingBar.v2.tsx +++ b/packages/shared/src/components/post/MobilePostFloatingBar.v2.tsx @@ -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'; @@ -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(); @@ -61,6 +63,8 @@ export function MobilePostFloatingBar({ onClose(true); } + recordUpvoteInteraction(isUpvoteActive); + await toggleUpvote({ payload: post, origin }); }; diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index d7b78d21711..ab76c6bc546 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -16,6 +16,7 @@ import { useMutationSubscription, useVotePost } from '../../hooks'; import { Origin } from '../../lib/log'; import { PostTagsPanel } from './block/PostTagsPanel'; import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel'; +import { useRecordUpvoteInteraction } from '../../hooks/post/usePostActions'; import { useBookmarkPost } from '../../hooks/useBookmarkPost'; import { ButtonColor, ButtonVariant } from '../buttons/Button'; import { BookmarkButton } from '../buttons'; @@ -53,6 +54,7 @@ function PostActionsV1({ const { showLogin, user } = useAuthContext(); const { openModal } = useLazyModal(); const { data, onShowPanel, onClose } = useBlockPostPanel(post); + const recordUpvoteInteraction = useRecordUpvoteInteraction({ post }); const { showTagsPanel } = data; const actionsRef = useRef(null); const canAward = useCanAwardUser({ @@ -93,6 +95,8 @@ function PostActionsV1({ onClose(true); } + recordUpvoteInteraction(isUpvoteActive); + await toggleUpvote({ payload: post, origin }); }; diff --git a/packages/shared/src/components/post/PostComments.spec.tsx b/packages/shared/src/components/post/PostComments.spec.tsx index 06235481f88..d6b9f0c15d5 100644 --- a/packages/shared/src/components/post/PostComments.spec.tsx +++ b/packages/shared/src/components/post/PostComments.spec.tsx @@ -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'; @@ -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 = {}, -): RenderResult => { +const renderComponent = (numComments: number): RenderResult => { mockRequestMethod.mockReturnValue(() => Promise.resolve(buildComments(numComments)), ); return render( - + { 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)); @@ -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)); diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index 42137bde596..1a04deab0a2 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -176,8 +176,9 @@ export function PostComments({ {!hideEndOfConversationShare && ( )} diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index b3797e253c2..515fc5e7c6e 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -63,7 +63,7 @@ function PostEngagements({ useSettingsContext(); const { user, showLogin } = useAuthContext(); const { isPlus } = usePlusSubscription(); - const commentRef = useRef(); + const commentRef = useRef(null); const [authorOnboarding, setAuthorOnboarding] = useState(false); const [permissionNotificationCommentId, setPermissionNotificationCommentId] = useState(); @@ -91,6 +91,7 @@ function PostEngagements({ setPermissionNotificationCommentId(comment.id); if ( + !!post.source && isSourcePublicSquad(post.source) && !post.source?.currentMember && !isJoinSquadBannerDismissed @@ -125,7 +126,10 @@ function PostEngagements({ origin={logOrigin} /> - + {/* `PostContainer` is a flex column with no gap, so margins do not + collapse: the sort row below already carries `mt-3`. Spending only + `mb-1` here lands 16px of air on both sides of the prompt. */} + {linkClicked && } Sort: @@ -136,7 +140,9 @@ function PostEngagements({ icon={ } onClick={() => @@ -176,7 +182,7 @@ function PostEngagements({ {authorOnboarding && ( showLogin({ trigger: AuthTriggers.Author })) + user ? undefined : () => showLogin({ trigger: AuthTriggers.Author }) } /> )} diff --git a/packages/shared/src/components/post/common/PostContentShare.spec.tsx b/packages/shared/src/components/post/common/PostContentShare.spec.tsx new file mode 100644 index 00000000000..c989c9f1b4e --- /dev/null +++ b/packages/shared/src/components/post/common/PostContentShare.spec.tsx @@ -0,0 +1,264 @@ +import type { ComponentProps } from 'react'; +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'; +import { LogEvent, Origin } from '../../../lib/log'; +import { ShareProvider } from '../../../lib/share'; + +jest.mock('../../../lib/func', () => { + const actual = jest.requireActual('../../../lib/func'); + return { + __esModule: true, + ...actual, + shouldUseNativeShare: jest.fn(), + }; +}); + +const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock; +const logEvent = jest.fn(); +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; +}; + +// The prompt is no longer flag-gated, so GrowthBook is set up only to satisfy +// the boot provider — no feature it defines changes what renders here. +const renderComponent = ( + client = createClient(), + props: Partial> = {}, +): void => { + const gb = new GrowthBook(); + + mockGraphQL({ + request: { query: GET_SHORT_URL_QUERY, variables: { url: trackedUrl } }, + result: { data: { getShortUrl: SHORT_LINK } }, + }); + + render( + + + , + ); +}; + +// The card is no longer the default treatment, so the tests that cover its +// tile row and close button have to ask for it by name. +const renderCard = (client = createClient()): void => + renderComponent(client, { promptVariant: 'card' }); + +describe('PostContentShare', () => { + it('renders the existing widget when asked for the control treatment', async () => { + renderComponent(createClient(), { promptVariant: 'control' }); + + 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(); + }); + + it('defaults to the flat band on top of the resolved short URL', async () => { + renderComponent(); + + expect(await screen.findByText('Enjoyed this post?')).toBeInTheDocument(); + expect( + screen.queryByText('Should anyone else see this post?'), + ).not.toBeInTheDocument(); + // One control rather than the tile row. jsdom reports a non-laptop + // viewport, which is the path where `ShareActions` drops the chevron and + // leaves a single button — so the absence of the tiles is what this + // asserts, not the presence of the dropdown. + expect(screen.getByText('Copy link')).toBeInTheDocument(); + expect(screen.queryByText('WhatsApp')).not.toBeInTheDocument(); + }); + + it('renders the card treatment when asked for it', async () => { + renderCard(); + + expect( + await screen.findByText('Good call. Now pass it on.'), + ).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(); + renderCard(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 }); + + renderCard(); + + 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(); + }); + + // The prompt keys off `usePostActions`, not `post.userState.vote`. Only the + // feed cards used to record that interaction, so upvoting from the post page + // itself left this empty and the prompt never appeared. Guard the empty case + // so a regression shows up here rather than as "the strip doesn't work". + it('logs a share event with the same payload shape as every other surface', async () => { + renderComponent(); + + await screen.findByText('Enjoyed this post?'); + + await act(async () => { + fireEvent.click(screen.getByText('Copy link')); + }); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.PostContent, + }), + }), + ), + ); + + // Once, not twice: `useShareOrCopyLink` logs its own event when handed a + // `logObject`, and `ShareActions` deliberately does not pass one. + const shareEvents = logEvent.mock.calls.filter( + ([event]) => event.event_name === LogEvent.SharePost, + ); + expect(shareEvents).toHaveLength(1); + }); + + it('logs the network the reader actually picked', async () => { + renderComponent(createClient(), { promptVariant: 'card' }); + + // `SocialShareList` runs the link through `getShortUrl` again on click, + // even though it is already the tracked short URL. + mockGraphQL({ + request: { query: GET_SHORT_URL_QUERY, variables: { url: SHORT_LINK } }, + result: { data: { getShortUrl: SHORT_LINK } }, + }); + + await screen.findByText('Good call. Now pass it on.'); + + await act(async () => { + fireEvent.click(screen.getByText('WhatsApp')); + }); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + extra: JSON.stringify({ + provider: ShareProvider.WhatsApp, + origin: Origin.PostContent, + }), + }), + ), + ); + }); + + it('renders nothing until an upvote interaction is recorded', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData( + generateQueryKey(RequestKey.PostActions, { id: post.id }), + { interaction: 'none', previousInteraction: 'none' }, + ); + + renderComponent(client); + + await waitFor(() => + expect(screen.queryByText('Enjoyed this post?')).not.toBeInTheDocument(), + ); + expect( + screen.queryByText('Should anyone else see this post?'), + ).not.toBeInTheDocument(); + }); + + it('dismisses the prompt from the close button', async () => { + renderCard(); + + 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(), + ); + }); +}); diff --git a/packages/shared/src/components/post/common/PostContentShare.tsx b/packages/shared/src/components/post/common/PostContentShare.tsx index f53b756c3ec..45431d6ccd6 100644 --- a/packages/shared/src/components/post/common/PostContentShare.tsx +++ b/packages/shared/src/components/post/common/PostContentShare.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import { InviteLinkInput } from '../../referral/InviteLinkInput'; import { Origin, LogEvent } from '../../../lib/log'; import type { Post } from '../../../graphql/posts'; @@ -9,45 +10,224 @@ import { ReferralCampaignKey, useGetShortUrl } from '../../../hooks'; import { PostContentWidget } from './PostContentWidget'; import { useActiveFeedContext } from '../../../contexts'; import { postLogEvent } from '../../../lib/feed'; +import { ShareActions } from '../../share/ShareActions'; +import { ShareBand } from '../../share/ShareBand'; +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, ButtonVariant } from '../../buttons/Button'; + +/** + * What the prompt contains, in descending weight: + * + * - `band` (default) — a single line: encouraging copy on the left, one split + * copy-link control on the right, the networks behind its chevron. Borrows + * its shape from `EndOfConversationShare`. + * - `hero` — the fuller block (upvote badge, headline, description, dismiss) + * with that same split control under it. + * - `card` — the same block with all eight networks laid out as tiles. + * - `control` — the "Should anyone else see this post?" widget shipping today. + * An explicit treatment rather than a flag state, so it stays comparable + * against the others now that the prompt is no longer flag-gated. + * + * If `band` or `hero` ships, its share control and `EndOfConversationShare`'s + * should come from one extracted component rather than these near-copies. + */ +export type PostContentSharePromptVariant = + | 'control' + | 'card' + | 'hero' + | 'band'; + +/** + * How the prompt is contained, independent of what it contains. + * + * `flat` (default) spends no chrome at all — no fill, no border, no rule. Its + * margins are matched top and bottom so it sits centred between the action bar + * above it and the comment box below, reading as one more line of the page + * rather than a section break. `card` puts it on its own float surface, the way + * the control widget sits in the post body today. + */ +export type PostContentShareSurface = 'flat' | 'card'; + +// Spacing deliberately lives outside these: `PostContainer` is a flex column +// with no gap and every child hand-rolls its own margins, so what reads as +// "equal air above and below" depends on the neighbours. Each host passes its +// own `className`; the default suits a container that supplies no gap. +const DEFAULT_SPACING = 'my-4'; + +const SURFACE_CLASS: Record = { + flat: '', + card: 'rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4', +}; + +const PROMPT_COPY = { + card: { + title: 'Good call. Now pass it on.', + description: + 'Send it to the one person who’ll actually read it. That’s how millions of developers find the good stuff on daily.dev.', + }, + band: { + title: 'Enjoyed this post?', + description: 'Send it to someone who’d have opinions.', + }, +}; interface PostContentShareProps { post: Post; + promptVariant?: PostContentSharePromptVariant; + surface?: PostContentShareSurface; + /** Vertical spacing, owned by the host — see `DEFAULT_SPACING`. */ + className?: string; + /** Copy overrides, while the wording is still being chosen. */ + title?: string; + description?: string; } export function PostContentShare({ post, + promptVariant = 'band', + surface = 'flat', + className = DEFAULT_SPACING, + title, + description, }: 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, }, }); - - if (interaction !== 'upvote' || isLoading) { + if (!isUpvoted || isLoading) { return null; } - return ( - - + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.PostContent }, + ...(logOpts && logOpts), + }); + + // Ships to everyone: no longer behind `share_upvote_prompt` or the + // `sharing_visibility` master gate, matching the end-of-conversation band in + // #6369. Today's widget stays available as an explicit treatment so the two + // can be compared side by side. + if (promptVariant === 'control') { + return ( + + onInteract('none')} + logProps={buildLogEvent(ShareProvider.CopyLink)} + /> + + ); + } + + if (promptVariant === 'band') { + // Same peak-intent moment, quieter footprint: the networks live behind the + // chevron, so the prompt reads as one line of encouragement plus a single + // control rather than a wall of tiles. No close button — the band is light + // enough to ignore, matching the end-of-conversation strip it shares its + // markup with. + return ( + onInteract('none')} - logProps={postLogEvent(LogEvent.SharePost, post, { - extra: { - provider: ShareProvider.CopyLink, - origin: Origin.PostContent, - }, - ...(logOpts && logOpts), - })} + text={shareText} + emailTitle={shareText} + className={classNames(SURFACE_CLASS[surface], className)} + onShare={(provider) => logEvent(buildLogEvent(provider))} /> - + ); + } + + // 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 ( +
+
+ + + +
+ + {promptTitle} + + + {promptDescription} + + {promptVariant === 'hero' && ( + // Same block, one control: the networks move behind the chevron, so + // the prompt keeps its prominence without an eight-tile row under + // it. It lives inside the text column rather than beside it — the + // header row already ends in the close button, and a second control + // there would read as a pair of dismissals. Nesting it here also + // lines its left edge up with the headline and description by + // construction, instead of hanging under the badge. + logEvent(buildLogEvent(provider))} + /> + )} +
+ onInteract('none')} + /> +
+ {promptVariant === 'card' && ( + logEvent(buildLogEvent(provider))} + /> + )} +
); } diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index de70d8284a9..9607e735bf6 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { UserVote } from '../../../graphql/posts'; import { useViewSize, useVotePost, ViewSize } from '../../../hooks'; +import { useRecordUpvoteInteraction } from '../../../hooks/post/usePostActions'; import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; import { useCanAwardUser } from '../../../hooks/useCoresFeature'; @@ -95,6 +96,7 @@ export const FocusCardActionBar = ({ return () => observer.disconnect(); }, []); + const recordUpvoteInteraction = useRecordUpvoteInteraction({ post }); const isUpvoteActive = post?.userState?.vote === UserVote.Up; const isDownvoteActive = post?.userState?.vote === UserVote.Down; const isAwarded = !!post?.userState?.awarded; @@ -172,6 +174,9 @@ export const FocusCardActionBar = ({ if (post?.userState?.vote === UserVote.None) { onCloseBlockPanel(true); } + + recordUpvoteInteraction(isUpvoteActive); + await toggleUpvote({ payload: post, origin }); }; diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9d0706c024b..7c36fc131f1 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -32,6 +32,7 @@ import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; +import { PostContentShare } from '../common/PostContentShare'; import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; @@ -570,6 +571,16 @@ export const PostFocusCard = ({ className="-mt-2" /> + {/* This layout does not go through `PostEngagements`, so the + post-upvote share prompt has to be mounted here too — otherwise + it exists only on the classic post page. Same placement in both: + directly under the action bar, above the discussion. + + Default spacing is right here: this column has its own `gap-4` + and the discussion below adds no top margin, so the prompt lands + with equal air on both sides. */} + +
{ + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index 50ee53e30e8..a375ea9caf7 100644 --- a/packages/shared/src/components/share/ShareActions.tsx +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -13,8 +13,10 @@ import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { shouldUseNativeShare } from '../../lib/func'; import { ShareProvider } from '../../lib/share'; import type { ReferralCampaignKey } from '../../lib/referral'; +import { CopyStateIcon } from './CopyStateIcon'; +import { SplitShareButton } from './SplitShareButton'; -export type ShareActionsVariant = 'icon' | 'inline'; +export type ShareActionsVariant = 'icon' | 'inline' | 'split'; export interface ShareActionsProps { link: string; @@ -28,6 +30,13 @@ export interface ShareActionsProps { buttonSize?: ButtonSize; /** Tooltip + accessible label for the icon-only trigger. */ label?: string; + /** + * Render `triggerText` beside the icon instead of an icon-only trigger. Gated + * so existing icon-only consumers keep their exact DOM. + */ + triggerText?: string; + /** `split` variant only: label for the chevron half that opens the list. */ + dropdownLabel?: string; emailTitle?: string; emailSummary?: string; className?: string; @@ -46,6 +55,8 @@ export function ShareActions({ buttonVariant = ButtonVariant.Tertiary, buttonSize = ButtonSize.Small, label = 'Copy link', + triggerText, + dropdownLabel = 'More share options', emailTitle, emailSummary, className, @@ -56,6 +67,22 @@ export function ShareActions({ const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); const closeTimeout = useRef>(); + const onCopy = () => { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }; + + // `copying` stays true for a second after a copy, which is the whole window + // for the confirmation. The green-check swap is scoped to the split control — + // icon-only triggers keep the existing `secondary` fill so this does not + // restyle every share surface in the app. + const copyIcon = + variant === 'split' ? ( + + ) : ( + + ); + const list = ( { - onShare?.(ShareProvider.CopyLink); - shareOrCopy(); - }} + onCopy={onCopy} onNativeShare={() => { onShare?.(ShareProvider.Native); shareOrCopy(); @@ -92,7 +116,7 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={copyIcon} aria-label={label} className={className} onClick={() => { @@ -103,11 +127,31 @@ export function ShareActions({ ); shareOrCopy(); }} - /> + > + {triggerText} + ); } + if (variant === 'split') { + return ( + + ); + } + const cancelClose = () => { if (closeTimeout.current) { clearTimeout(closeTimeout.current); @@ -136,12 +180,14 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={copyIcon} aria-label={label} pressed={open} className={className} {...hoverProps} - /> + > + {triggerText} + void; +} + +/** + * One line of encouraging copy beside a single split copy-link control, with + * the social networks behind its chevron. + * + * Shared by the two surfaces that prompt a share: `EndOfConversationShare` + * below an active discussion, and `PostContentShare` right after an upvote. + * They differ only in copy, link and placement — everything visual lives here + * so the two cannot drift apart. + */ +export const ShareBand = ({ + title, + description, + link, + text, + cid, + emailTitle, + className, + onShare, +}: ShareBandProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/share/SplitShareButton.tsx b/packages/shared/src/components/share/SplitShareButton.tsx new file mode 100644 index 00000000000..1c06db85088 --- /dev/null +++ b/packages/shared/src/components/share/SplitShareButton.tsx @@ -0,0 +1,181 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { CopyStateIcon, EASE_OUT_EXPO } from './CopyStateIcon'; + +export interface SplitShareButtonProps { + /** Tooltip and accessible name for the copy half. */ + label: string; + /** Accessible name for the chevron half. */ + dropdownLabel: string; + /** Visible text on the copy half; falls back to `label`. */ + triggerText?: string; + /** Contents of the dropdown the chevron opens. */ + menu: ReactNode; + /** Drives the copy-glyph confirmation swap. */ + copied: boolean; + onCopy: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule. Every other + * variant returns nothing and keeps its real border as the divider. + * + * Alpha is mixed into the colour rather than applied as a separate utility: + * `before:opacity-*` does not survive this project's Tailwind build on + * pseudo-elements and silently renders at full strength. `bg-current` is + * likewise unusable — the theme replaces Tailwind's `colors` wholesale and has + * no `current` key, so it compiles to nothing at all. + */ +const dividerFor = (variant: ButtonVariant): string | false => { + // Sits on a solid fill, where only the label colour is guaranteed to read. + if (variant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + // A bare ghost button: a full-height rule would float with nothing to anchor + // it, so it gets a shorter one in the colour `tailwind/buttons.ts` gives the + // Subtle variant's border. + if (variant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * Two real buttons that read as one control: the left half runs the primary + * action, the right half drops the standard menu. Geometry matches a standard + * button at every size — only the shared edge deviates. + */ +export const SplitShareButton = ({ + label, + dropdownLabel, + triggerText, + menu, + copied, + onCopy, + variant = ButtonVariant.Tertiary, + size = ButtonSize.Small, + className, +}: SplitShareButtonProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/shared/src/hooks/post/usePostActions.ts b/packages/shared/src/hooks/post/usePostActions.ts index 4a9731bee5f..ef1bc3e7482 100644 --- a/packages/shared/src/hooks/post/usePostActions.ts +++ b/packages/shared/src/hooks/post/usePostActions.ts @@ -69,3 +69,31 @@ export const usePostActions = ({ post }: { post?: Post }): UsePostActions => { onInteract, }; }; + +/** + * Record an upvote the way the feed cards do, for the post page's own action + * bars. + * + * Surfaces that fire off the back of an upvote — `PostContentShare` — read this + * interaction, not `post.userState.vote`. Only `useCardActions` used to set it, + * so upvoting anywhere on the post page left it empty and those surfaces never + * appeared. Every post-page bar that owns an upvote button has to call this: + * `PostActions`, `FocusCardActionBar`, and both `MobilePostFloatingBar`s. + * + * Deliberately not folded into `useVotePost`. That would fire for feed-card + * upvotes too, and `useCardCover` turns `interaction === 'upvote'` into a share + * overlay on the card — so an upvote from the post page would leave an overlay + * waiting back in the feed. + */ +export const useRecordUpvoteInteraction = ({ + post, +}: { + post?: Post; +}): ((isUpvoteActive: boolean) => void) => { + const { onInteract } = usePostActions({ post }); + + return useCallback( + (isUpvoteActive: boolean) => onInteract(isUpvoteActive ? 'none' : 'upvote'), + [onInteract], + ); +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index f585086aff7..97b38cbdd2b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,12 +308,3 @@ 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/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 267f120534f..67ccad6c3bd 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Preview, ReactRenderer } from '@storybook/react-vite'; import { withThemeByClassName } from '@storybook/addon-themes'; +import { MINIMAL_VIEWPORTS } from 'storybook/viewport'; import '@dailydotdev/shared/src/styles/globals.css'; import { initialize, mswLoader } from 'msw-storybook-addon'; @@ -11,6 +12,23 @@ initialize({ const preview: Preview = { parameters: { controls: { expanded: true }, + viewport: { + options: { + ...MINIMAL_VIEWPORTS, + /** + * Components that branch on `useViewSize(ViewSize.Laptop)` read the + * story iframe's width, and that iframe is the window minus the + * sidebar and the addons panel — often under the 1020px laptop + * breakpoint. A story that needs the desktop path has to pin its own + * frame rather than hope the reviewer's panel layout is wide enough. + */ + laptop: { + name: 'Laptop (desktop path)', + type: 'desktop', + styles: { width: '1280px', height: '900px' }, + }, + }, + }, options: { storySort: { order: [ diff --git a/packages/storybook/stories/components/EndOfConversationShare.stories.tsx b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx index 525fa35437e..9fe6748606b 100644 --- a/packages/storybook/stories/components/EndOfConversationShare.stories.tsx +++ b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx @@ -1,15 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { activeDiscussionCommentThreshold, - EndOfConversationShareBand, + EndOfConversationShare, } 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'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/common'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { shareDecorator } from './share.mocks'; const basePost = { id: 'p1', @@ -22,91 +28,40 @@ const basePost = { const withPost = (numComments: number): Post => ({ ...basePost, numComments } as Post); -const meta: Meta = { +const meta: Meta = { title: 'Components/Share/EndOfConversationShare', - component: EndOfConversationShareBand, + component: EndOfConversationShare, 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.`, +\`Post.numComments\`. It ships to everyone — the +comment threshold is the only condition.`, }, }, }, 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, - }} - > -
- -
-
-
-
- ); - }, + shareDecorator('flex min-h-40 w-full max-w-2xl flex-col justify-center'), ], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; -// Active discussion: the band renders below the comment list. +// Active discussion in the default flat treatment: no fill, no rounding, just +// a hairline rule above the strip. export const ActiveDiscussion: Story = { args: { post: withPost(12) }, }; +// The heavier alternative: a self-contained float surface with a full border. +export const Card: Story = { + args: { post: withPost(12), variant: 'card' }, +}; + // Exactly at the threshold — still hidden, the band needs MORE than 3 comments. export const AtThreshold: Story = { args: { post: withPost(activeDiscussionCommentThreshold) }, @@ -122,16 +77,252 @@ 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). +const Section = ({ + title, + note, + children, +}: { + title: string; + note: string; + children: React.ReactNode; +}) => ( +
+
+ + {title} + + + {note} + +
+ {children} +
+); + +// A hidden state renders nothing at all, so the outline marks where the band +// would have been — otherwise the gallery just shows a gap. +const Hidden = ({ post }: { post: Post }) => ( +
+ + Nothing renders ({post.numComments ?? 0} comments) + + +
+); + +const commentAuthors = ['Ido Shamun', 'Nimrod Kramer', 'Tsahi Matsliah']; + +// Stand-in for the comment list so the band can be judged against what sits +// above it, without dragging the real Comment component's context in. +const CommentSkeletons = () => ( + <> + {commentAuthors.map((author) => ( +
+
+
+ + {author} + +
+
+
+
+ ))} + +); + +/** + * Rendered at a real phone viewport, not a narrow box on a desktop viewport — + * the `tablet:` breakpoint reads the viewport, so only a true 390px frame shows + * the stacked layout and the chevron-less single tap. `AllStates` embeds this + * story in a 390px iframe for exactly that reason. + */ export const Mobile: Story = { - args: { post: withPost(12) }, - decorators: [ - (Story) => ( -
- + render: () => ( +
+
+ +
- ), - ], + +
+ ), +}; + +// Storybook renders each story in its own iframe, so embedding one here gives +// the band a genuine 390px viewport instead of a squeezed desktop layout. The +// theme global is mirrored so the frame follows the toolbar toggle. +const MobileFrame = () => { + const [isDark, setIsDark] = React.useState(true); + + React.useEffect(() => { + const read = () => + setIsDark(document.documentElement.classList.contains('dark')); + read(); + + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return ( +