From ba44e1f1dd3f46e36e691dc3ac89d43ba2462f72 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 10:19:26 +0300 Subject: [PATCH 1/5] refactor(share): extract ShareBand from EndOfConversationShare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-upvote prompt needs the same one-line band: encouraging copy beside a split copy-link control. Pulling it into `ShareBand` keeps the two surfaces from drifting rather than growing a second copy of the markup. Callers keep what actually differs — copy, link, and the surface classes, since one sits below the comment list and the other in the post body. Co-Authored-By: Claude Opus 5 --- .../post/EndOfConversationShare.tsx | 46 +++-------- .../shared/src/components/share/ShareBand.tsx | 81 +++++++++++++++++++ 2 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 packages/shared/src/components/share/ShareBand.tsx diff --git a/packages/shared/src/components/post/EndOfConversationShare.tsx b/packages/shared/src/components/post/EndOfConversationShare.tsx index f3a4d4402bc..e05e09a7c10 100644 --- a/packages/shared/src/components/post/EndOfConversationShare.tsx +++ b/packages/shared/src/components/post/EndOfConversationShare.tsx @@ -2,13 +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 { ShareBand } from '../share/ShareBand'; import { useLogContext } from '../../contexts/LogContext'; import { postLogEvent } from '../../lib/feed'; import { LogEvent, Origin } from '../../lib/log'; @@ -62,42 +56,20 @@ export const EndOfConversationShare = ({ ); return ( - + onShare={onShare} + /> ); }; diff --git a/packages/shared/src/components/share/ShareBand.tsx b/packages/shared/src/components/share/ShareBand.tsx new file mode 100644 index 00000000000..7ed39dedc4f --- /dev/null +++ b/packages/shared/src/components/share/ShareBand.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ShareActions } from './ShareActions'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; + +export interface ShareBandProps { + title: string; + description: string; + link: string; + /** Share text / description used for native share + pre-filled network text. */ + text: string; + /** Omit when `link` is already a tracked short URL — passing it double-shortens. */ + cid?: ReferralCampaignKey; + emailTitle?: string; + /** Surface and spacing belong to the host: the two callers sit in different places. */ + className?: string; + onShare: (provider: ShareProvider) => 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 => ( + +); From 86421f18faf977af223d01abfedc59adc200ac8f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 10:19:26 +0300 Subject: [PATCH 2/5] fix(post): record the upvote interaction from the post page's action bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces that fire off the back of an upvote read `usePostActions().interaction`, not `post.userState.vote`. Only `useCardActions` — the feed cards — ever recorded it, so upvoting anywhere on the post page left it empty and those surfaces never appeared. On mobile, where the upvote lives in `MobilePostFloatingBar`, that covered every path. Adds `useRecordUpvoteInteraction` and wires the four post-page bars through it, so a fifth surface has one obvious thing to call. Deliberately not folded into `useVotePost`, which would cover every surface at once: `useCardCover` turns `interaction === 'upvote'` into a share overlay on the feed card, so an upvote from the post page would leave an overlay waiting back in the feed. Co-Authored-By: Claude Opus 5 --- .../components/post/MobilePostFloatingBar.tsx | 4 +++ .../post/MobilePostFloatingBar.v2.tsx | 4 +++ .../src/components/post/PostActions.tsx | 4 +++ .../post/focus/FocusCardActionBar.tsx | 5 ++++ .../shared/src/hooks/post/usePostActions.ts | 28 +++++++++++++++++++ 5 files changed, 45 insertions(+) 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/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/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], + ); +}; From cd348f31757920dcc814516886bc75ec1ba11820 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 10:20:55 +0300 Subject: [PATCH 3/5] feat(share): flat share band after an upvote, for everyone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "Should anyone else see this post?" copy-link widget with a prompt that reads as part of the page: one flat line of encouraging copy beside a split copy-link control, the social networks behind its chevron. Two independent axes, so the treatment can be chosen without touching layout code: - `promptVariant` — band (default), hero, card, or control - `surface` — flat (default) or card Ships to everyone rather than behind a flag, matching the end-of-conversation band; today's widget survives as the `control` treatment. Also mounts the prompt in `PostFocusCard`. That layout does not go through `PostEngagements`, so it never rendered the prompt at all — anything added there is invisible on the redesigned post page. The strict-type fixes in `PostEngagements` are collateral: the repo's changed-file guard fails the file otherwise. Co-Authored-By: Claude Opus 5 --- .../src/components/post/PostEngagements.tsx | 14 +- .../post/common/PostContentShare.spec.tsx | 198 ++++++++++++++++ .../post/common/PostContentShare.tsx | 218 ++++++++++++++++-- .../components/post/focus/PostFocusCard.tsx | 11 + 4 files changed, 418 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/components/post/common/PostContentShare.spec.tsx 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..0d0febec4ec --- /dev/null +++ b/packages/shared/src/components/post/common/PostContentShare.spec.tsx @@ -0,0 +1,198 @@ +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'; + +jest.mock('../../../lib/func', () => { + const actual = jest.requireActual('../../../lib/func'); + return { + __esModule: true, + ...actual, + shouldUseNativeShare: jest.fn(), + }; +}); + +const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock; +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('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/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. */} + +
Date: Tue, 28 Jul 2026 10:20:55 +0300 Subject: [PATCH 4/5] docs(storybook): stories and playground for the share prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every state of the prompt: four treatments, both surfaces, both themes, mobile at a real 390px, the two states where it renders nothing, and its placement on a mock post page. The playground drives treatment, surface and copy from one screen so the wording can be picked against the real control. Stories pin a 1280px viewport: `ShareActions` drops its chevron below 1020px measured against the story iframe — the window minus the sidebar and addons panel — so the split control otherwise showed as a lone copy button depending on the reviewer's panel layout. Co-Authored-By: Claude Opus 5 --- packages/storybook/.storybook/preview.tsx | 18 + .../components/PostContentShare.stories.tsx | 424 ++++++++++++++ .../PostContentSharePlayground.stories.tsx | 535 ++++++++++++++++++ .../stories/components/share.mocks.tsx | 165 +++++- 4 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 packages/storybook/stories/components/PostContentShare.stories.tsx create mode 100644 packages/storybook/stories/components/PostContentSharePlayground.stories.tsx 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/PostContentShare.stories.tsx b/packages/storybook/stories/components/PostContentShare.stories.tsx new file mode 100644 index 00000000000..29498c5f166 --- /dev/null +++ b/packages/storybook/stories/components/PostContentShare.stories.tsx @@ -0,0 +1,424 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { PostContentShare } from '@dailydotdev/shared/src/components/post/common/PostContentShare'; +import type { HarnessProps } from './share.mocks'; +import { + longTitlePost, + PostSharePromptHarness as Harness, + SHORT_LINK, + upvotedPost as post, +} from './share.mocks'; + +const withHarness = + (options: Omit) => + (Story: React.ComponentType): ReactElement => + ( + +
+ +
+
+ ); + +// The component returns `null` in these states, so the canvas would otherwise +// be blank with nothing to tell a reviewer whether that's the point or a bug. +const withEmptyStateNote = + (note: string) => + (Story: React.ComponentType): ReactElement => + ( +
+

{note}

+
+ +
+
+ ); + +const meta: Meta = { + title: 'Components/Share/PostContentShare', + component: PostContentShare, + args: { post }, + tags: ['autodocs'], + // Pin the desktop path. `ShareActions` drops the chevron below 1020px, and it + // measures the story iframe — which is the window minus the sidebar and the + // addons panel, often narrower than that. Without this, the split control + // shows as a lone copy button depending on the reviewer's panel layout. The + // mobile stories below override it, which is the only place that collapse is + // meant to be seen. + globals: { viewport: { value: 'laptop' } }, + parameters: { + docs: { + description: { + component: [ + 'Post-upvote share prompt, in four treatments. Start at `FourWayComparison`.', + '', + '- **Control** — the plain "Should anyone else see this post?" copy-link widget shipping today.', + '- **Band** (`promptVariant="band"`) — a single line: encouraging copy on the left, one split copy-link control on the right, social networks a chevron away.', + '- **Hero** (`promptVariant="hero"`) — the prominent card, with the tile row swapped for that same split control.', + '- **Card** (`promptVariant="card"`) — the full block with eight social tiles always on screen.', + '', + 'The prompt ships to everyone — no feature flag — and renders nothing at all when the post is not upvoted or the tracked short link has not resolved yet. `control` is one of the treatments rather than a flag state.', + '', + 'Stories with a `play` function do not auto-run on this docs page — open them in the Canvas tab to see the interaction.', + ].join('\n'), + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// -- Hero: the card, with the networks in a dropdown ------------------------ + +// The prominent block keeps its upvote badge, headline and close button; only +// the eight-tile row changes, into the same split control the band uses. +export const Hero: Story = { + args: { promptVariant: 'hero' }, + decorators: [withHarness({})], +}; + +export const HeroDark: Story = { + args: { promptVariant: 'hero' }, + decorators: [withHarness({})], + globals: { theme: 'dark', viewport: { value: 'laptop' } }, +}; + +// The networks, one tap in. +export const HeroDropdownOpen: Story = { + args: { promptVariant: 'hero' }, + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText('More share options')); + + // Radix portals the menu outside the story canvas. + const menu = within(document.body); + await waitFor(() => + expect(menu.getByTestId('social-share-WhatsApp')).toBeInTheDocument(), + ); + }, +}; + +// Same mobile collapse as the band: below laptop width the split control +// becomes one button straight to the OS share sheet, so the hero and the card +// diverge most on the viewport where most posts are read. +export const HeroMobile: Story = { + args: { promptVariant: 'hero' }, + decorators: [withHarness({})], + globals: { viewport: { value: 'mobile1' } }, +}; + +// -- Band: the in-between treatment ----------------------------------------- + +// Encouraging copy plus a single split control: copy on the left half, chevron +// on the right opening the social list. Lighter than the card, warmer than +// today's widget. +export const Band: Story = { + args: { promptVariant: 'band' }, + decorators: [withHarness({})], +}; + +// The band on its own float surface — the heavier alternative now that flat +// (no fill, no border, no rule) is the default. +export const BandOnCardSurface: Story = { + args: { promptVariant: 'band', surface: 'card' }, + decorators: [withHarness({})], +}; + +export const BandDark: Story = { + args: { promptVariant: 'band' }, + decorators: [withHarness({})], + globals: { theme: 'dark', viewport: { value: 'laptop' } }, +}; + +// At 375px the row stacks and centres: copy above, control below. +// +// Note the chevron is gone. `ShareActions` short-circuits below laptop width +// for every non-inline variant, so the split control collapses to one button +// that opens the OS share sheet (falling back to copy where there is none). +// That is 6369's deliberate mobile path, not a layout bug — but it does mean +// the band offers a single tap on mobile where the card offers eight tiles. +export const BandMobile: Story = { + args: { promptVariant: 'band' }, + decorators: [withHarness({})], + globals: { viewport: { value: 'mobile1' } }, +}; + +// The networks are one tap away rather than always on screen — this is the +// trade the band makes against the card. +export const BandDropdownOpen: Story = { + args: { promptVariant: 'band' }, + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText('More share options')); + + // Radix portals the menu outside the story canvas. + const menu = within(document.body); + await waitFor(() => + expect(menu.getByTestId('social-share-WhatsApp')).toBeInTheDocument(), + ); + }, +}; + +// Copy confirmation: the glyph cross-fades to a green check. Same 1s timer as +// the card variant, held open here so there is something to look at. +export const BandCopying: Story = { + args: { promptVariant: 'band' }, + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => undefined }, + }); + + const realSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (( + handler: TimerHandler, + ms?: number, + ...rest: unknown[] + ) => + ms === 1000 + ? 0 + : realSetTimeout(handler, ms, ...rest)) as typeof setTimeout; + + try { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Copy link' })); + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Copy link' })).toBeEnabled(), + ); + } finally { + globalThis.setTimeout = realSetTimeout; + } + }, +}; + +// Copy candidates, same control underneath — for picking wording, not layout. +const BAND_COPY_OPTIONS = [ + { + title: 'Enjoyed this post?', + description: 'Send it to someone who’d have opinions.', + }, + { + title: 'Good call. Now pass it on.', + description: 'Send it to the one person who’ll actually read it.', + }, + { + title: 'Worth someone else’s time?', + description: 'One tap to put it in front of them.', + }, + { + title: 'You upvoted it.', + description: 'Someone you know would want to read it too.', + }, +]; + +export const BandCopyOptions: Story = { + render: (args) => ( +
+ {BAND_COPY_OPTIONS.map((copy) => ( +
+

{copy.title}

+ + + +
+ ))} +
+ ), +}; + +// -- Variant (flag on) ------------------------------------------------------ + +// The redesigned prompt at the peak-intent moment right after an upvote. +export const Redesigned: Story = { + decorators: [withHarness({})], +}; + +// Same card on dark. Semantic tokens only — no hardcoded colours to drift. +export const RedesignedDark: Story = { + decorators: [withHarness({})], + globals: { theme: 'dark', viewport: { value: 'laptop' } }, +}; + +// Not a visual state: the card's copy is fixed, so the post title never +// reaches the screen — it only feeds the share payload. This story asserts the +// outgoing network URL carries the full headline and the tracked short link, +// rather than pretending there is something to look at. Check the Interactions +// panel, not the pixels. +export const LongTitleSharePayload: Story = { + args: { post: longTitlePost }, + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + const opened: string[] = []; + const realOpen = globalThis.open; + globalThis.open = ((url?: string | URL) => { + opened.push(String(url)); + return null; + }) as typeof globalThis.open; + + try { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('social-share-X')); + await waitFor(() => expect(opened).toHaveLength(1)); + + // The share URL carries both as percent-encoded query params. + const decoded = decodeURIComponent(opened[0]); + expect(decoded).toContain(longTitlePost.title); + expect(decoded).toContain(SHORT_LINK); + } finally { + globalThis.open = realOpen; + } + }, +}; + +// Copy tapped: the chip flips to "Copied!" and the card stays mounted, so a +// second destination is still one tap away. The clipboard is stubbed because +// the Storybook iframe isn't allowed to write to the real one. +// +// `useCopyLink` clears `copying` on a 1s timer, which would leave nothing to +// look at a beat after the story loads. Swallowing that one 1s callback holds +// the confirmation on screen for review; the original `setTimeout` is restored +// straight after, so nothing else in the iframe is affected. +export const RedesignedCopying: Story = { + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => undefined }, + }); + + const realSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (( + handler: TimerHandler, + ms?: number, + ...rest: unknown[] + ) => + ms === 1000 + ? 0 + : realSetTimeout(handler, ms, ...rest)) as typeof setTimeout; + + try { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('social-share-Copy link')); + await waitFor(() => + expect(canvas.getByText('Copied!')).toBeInTheDocument(), + ); + } finally { + globalThis.setTimeout = realSetTimeout; + } + }, +}; + +// Dismissed: the explicit close button is the only way out of the card, and it +// takes the prompt away for the rest of the session. +export const RedesignedDismissed: Story = { + decorators: [withHarness({})], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText('Dismiss share prompt')); + await waitFor(() => + expect(canvas.queryByText('Good call. Now pass it on.')).toBeNull(), + ); + }, +}; + +// Narrow viewport: the share row centres and wraps onto two rows. +export const RedesignedMobile: Story = { + decorators: [withHarness({})], + globals: { viewport: { value: 'mobile1' } }, +}; + +// Mobile with a native share sheet available — the extra "Share via…" chip at +// the end of the row. This is what most real mobile traffic sees. +export const RedesignedMobileNativeShare: Story = { + decorators: [withHarness({ nativeShare: true })], + globals: { viewport: { value: 'mobile1' } }, +}; + +// -- Control (flag off) ----------------------------------------------------- + +// Flag off — must render exactly what ships today. +export const Control: Story = { + args: { promptVariant: 'control' }, + decorators: [withHarness({})], +}; + +// Today's widget on dark, for a like-for-like comparison with the variant. +export const ControlDark: Story = { + args: { promptVariant: 'control' }, + decorators: [withHarness({})], + globals: { theme: 'dark', viewport: { value: 'laptop' } }, +}; + +// Today's widget at 375px — the copy-link input keeps its single row. +export const ControlMobile: Story = { + args: { promptVariant: 'control' }, + decorators: [withHarness({})], + globals: { viewport: { value: 'mobile1' } }, +}; + +// -- Nothing rendered ------------------------------------------------------- + +// No upvote, no prompt — the widget sits on the post page at all times and +// gates itself on the interaction. +export const HiddenNotUpvoted: Story = { + decorators: [ + withHarness({ upvoted: false }), + withEmptyStateNote( + 'Not upvoted → the component returns null. The dashed box is story chrome; the component itself renders nothing inside it.', + ), + ], +}; + +// The prompt waits for the tracked short link rather than flashing the long +// URL and swapping it — so there is no intermediate visual state to review. +export const HiddenUntilLinkResolves: Story = { + decorators: [ + withHarness({ linkResolved: false }), + withEmptyStateNote( + 'Short link unresolved → the component returns null until `useGetShortUrl` settles. The dashed box is story chrome; the component itself renders nothing inside it.', + ), + ], +}; + +// -- Side by side ----------------------------------------------------------- + +// All four treatments stacked at one width, which is the only fair way to +// compare how much room each one takes in the post body. +export const FourWayComparison: Story = { + render: (args) => ( +
+ {[ + { + label: '1 — Control, shipping today', + props: { promptVariant: 'control' as const }, + }, + { + label: '2 — Band: one line, networks in the dropdown', + props: { promptVariant: 'band' as const }, + }, + { + label: '3 — Hero: the card, networks in the dropdown', + props: { promptVariant: 'hero' as const }, + }, + { + label: '4 — Card: all eight networks inline', + props: { promptVariant: 'card' as const, surface: 'card' as const }, + }, + ].map(({ label, props }) => ( +
+

{label}

+ + + +
+ ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/PostContentSharePlayground.stories.tsx b/packages/storybook/stories/components/PostContentSharePlayground.stories.tsx new file mode 100644 index 00000000000..16f3a7a0eb1 --- /dev/null +++ b/packages/storybook/stories/components/PostContentSharePlayground.stories.tsx @@ -0,0 +1,535 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import type { PostContentSharePromptVariant } from '@dailydotdev/shared/src/components/post/common/PostContentShare'; +import { PostContentShare } from '@dailydotdev/shared/src/components/post/common/PostContentShare'; +import { EndOfConversationShare } from '@dailydotdev/shared/src/components/post/EndOfConversationShare'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PostSharePromptHarness, upvotedPost } from './share.mocks'; + +/* -------------------------------------------------------------------------- */ +/* Shared chrome */ +/* -------------------------------------------------------------------------- */ + +const Section = ({ + title, + note, + children, +}: { + title: string; + note: ReactNode; + children: ReactNode; +}) => ( +
+
+ + {title} + + + {note} + +
+ {children} +
+); + +/** + * Stand-ins for what the prompt actually sits between on the post page, so + * placement can be judged without dragging the real post page's contexts in. + * Order mirrors `PostEngagements`: body → counts → action bar → prompt → sort → + * comments. + */ +const PostBodySkeleton = () => ( +
+ + {upvotedPost.title} + + {[1, 0.95, 0.9, 0.75].map((width) => ( +
+ ))} +
+); + +const ActionBarSkeleton = () => ( +
+ + 42 Upvotes · 12 Comments + + {/* `PostActions` is a rounded, bordered box — not a full-width rule. The + difference matters here: a flat prompt underneath is judged against + whatever edge sits above it. */} +
+ {['Upvote', 'Comment', 'Bookmark', 'Copy link'].map((action) => ( +
+ {action} +
+ ))} +
+
+); + +const CommentsSkeleton = () => ( +
+ + Sort: Newest first + + {['Ido Shamun', 'Nimrod Kramer', 'Tsahi Matsliah'].map((author) => ( +
+
+
+ + {author} + +
+
+
+
+ ))} +
+); + +/* -------------------------------------------------------------------------- */ +/* Treatments */ +/* -------------------------------------------------------------------------- */ + +type TreatmentKey = 'control' | 'band' | 'hero' | 'card'; + +interface Treatment { + key: TreatmentKey; + name: string; + props: { + promptVariant?: PostContentSharePromptVariant; + surface?: 'flat' | 'card'; + }; + what: string; + networks: string; +} + +const TREATMENTS: Treatment[] = [ + { + key: 'control', + name: 'Control', + props: { promptVariant: 'control' }, + what: 'What ships today: a question and a read-only link input with a copy button.', + networks: 'None — copy link only.', + }, + { + key: 'band', + name: 'Band (default)', + props: { promptVariant: 'band' }, + what: 'The default: one flat line — encouraging copy on the left, a split copy-link control on the right, and no chrome at all. No fill, no border, no rule; matched margins so it sits centred between the action bar and the comment box.', + networks: 'Behind the chevron.', + }, + { + key: 'hero', + name: 'Hero', + props: { promptVariant: 'hero' }, + what: 'The fuller block — upvote badge, headline, description, dismiss — with the same split control under it, aligned to the copy. Flat by default too; switch the surface to see it boxed.', + networks: 'Behind the chevron.', + }, + { + key: 'card', + name: 'Card', + props: { promptVariant: 'card', surface: 'card' }, + what: 'The variant currently on PR 6351: the same block with all eight networks as tiles, on its own float surface.', + networks: 'All eight, always on screen.', + }, +]; + +const Prompt = ({ + treatment, + ...props +}: { + treatment: Treatment; + title?: string; + description?: string; +}) => ( + + + +); + +const meta: Meta = { + title: 'Components/Share/PostContentShare Playground', + // See the note in PostContentShare.stories.tsx: the split control drops its + // chevron below 1020px, measured against the story iframe rather than the + // screen, so the desktop path has to pin its own frame. `MobileStack` + // overrides this. + globals: { viewport: { value: 'laptop' } }, + parameters: { + docs: { + description: { + component: [ + 'Everything the post-upvote share prompt can be, in one place: what each treatment is, where it sits on the post page, and a playground for clicking through them.', + '', + 'The prompt renders from `PostEngagements`, directly below the upvote/comment action bar and above the comment sort control — so it is the first thing under the button the reader just pressed.', + ].join('\n'), + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/* -------------------------------------------------------------------------- */ +/* 1. Playground */ +/* -------------------------------------------------------------------------- */ + +const COPY_OPTIONS = [ + { label: 'Default (per treatment)', title: undefined, description: undefined }, + { + label: 'Enjoyed this post?', + title: 'Enjoyed this post?', + description: 'Send it to someone who’d have opinions.', + }, + { + label: 'Good call', + title: 'Good call. Now pass it on.', + description: 'Send it to the one person who’ll actually read it.', + }, + { + label: 'Worth someone’s time', + title: 'Worth someone else’s time?', + description: 'One tap to put it in front of them.', + }, + { + label: 'You upvoted it', + title: 'You upvoted it.', + description: 'Someone you know would want to read it too.', + }, +]; + +const Toggle = ({ + options, + value, + onChange, +}: { + options: { key: string; label: string }[]; + value: string; + onChange: (key: string) => void; +}) => ( +
+ {options.map((option) => ( + + ))} +
+); + +const Field = ({ label, children }: { label: string; children: ReactNode }) => ( +
+ + {label} + + {children} +
+); + +const PlaygroundView = () => { + const [treatmentKey, setTreatmentKey] = useState('band'); + const [surface, setSurface] = useState<'flat' | 'card'>('flat'); + const [copyIndex, setCopyIndex] = useState(0); + const [inContext, setInContext] = useState(true); + // Remounting the harness resets the seeded query cache, which is how a + // dismissed prompt (or a fired copy confirmation) comes back. + const [runId, setRunId] = useState(0); + + const treatment = + TREATMENTS.find((item) => item.key === treatmentKey) ?? TREATMENTS[0]; + const copy = COPY_OPTIONS[copyIndex]; + const props = { + ...treatment.props, + surface, + title: copy.title, + description: copy.description, + }; + + return ( +
+
+ + ({ key, label: name }))} + value={treatmentKey} + onChange={(key) => setTreatmentKey(key as TreatmentKey)} + /> + + + {treatmentKey !== 'control' && ( + + setSurface(key as 'flat' | 'card')} + /> + + )} + + {treatmentKey !== 'control' && ( + + ({ + key: String(index), + label: option.label, + }))} + value={String(copyIndex)} + onChange={(key) => setCopyIndex(Number(key))} + /> + + )} + + +
+ setInContext(key === 'context')} + /> + +
+
+ + + {treatment.what} Networks: {treatment.networks} + +
+ +
+ {inContext ? ( +
+ + + + +
+ ) : ( + + )} +
+
+ ); +}; + +/** + * Click through every treatment, copy option and surface, on the post page or + * on its own. Everything is live: copy really copies, the chevron really opens + * the network list, dismiss really dismisses — "Reset prompt" brings it back. + */ +export const Playground: Story = { + render: () => , +}; + +/* -------------------------------------------------------------------------- */ +/* 2. What each treatment is */ +/* -------------------------------------------------------------------------- */ + +// The four treatments with their descriptions, at one width. This is the page +// to read first — every other story is a detail of one of these. +export const AllTreatments: Story = { + render: () => ( +
+ {TREATMENTS.map((treatment, index) => ( +
+ {treatment.what} Networks: {treatment.networks} + + } + > + +
+ ))} +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* 3. Where it sits */ +/* -------------------------------------------------------------------------- */ + +// `PostEngagements` renders the prompt directly under the action bar and above +// the comment sort control — the first thing below the button just pressed. +export const WhereItSits: Story = { + render: () => ( +
+ + +
+ + ↓ PostContentShare renders here, only after the reader upvotes + + +
+ +
+ ), +}; + +// Both share surfaces on one post: this prompt under the action bar, and the +// end-of-conversation band (PR 6369) under the comment list. Worth agreeing +// they can co-exist before either ships. +export const TogetherWithTheEndOfConversationBand: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* 4. Mobile */ +/* -------------------------------------------------------------------------- */ + +// Rendered at a real phone viewport by `MobileFrames` below. Every treatment, +// stacked — this is where band and hero lose the chevron. +export const MobileStack: Story = { + globals: { viewport: { value: 'mobile1' } }, + render: () => ( +
+ {TREATMENTS.map((treatment) => ( +
+ + {treatment.name} + + +
+ ))} +
+ ), +}; + +// Storybook renders each story in its own iframe, so embedding one here gives +// the prompts a genuine 390px viewport instead of a squeezed desktop layout — +// which matters because `ShareActions` switches on the viewport, not on width. +// The theme global is mirrored so the frame follows the toolbar toggle. +const MobileFrame = (): ReactElement => { + const [isDark, setIsDark] = useState(true); + + 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 ( +