diff --git a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.spec.tsx b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.spec.tsx new file mode 100644 index 00000000000..a695340d8b7 --- /dev/null +++ b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { PopularHotTakes } from './PopularHotTakesList'; +import { PopularHotTakesList } from './PopularHotTakesList'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import loggedUser from '../../../../__tests__/fixture/loggedUser'; +import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled'; + +jest.mock('../../../hooks/useHotTakeShareEnabled', () => ({ + useHotTakeShareEnabled: jest.fn(), +})); + +const shareEnabledMock = useHotTakeShareEnabled as jest.Mock; +const shareLabel = 'Share the hot takes leaderboard'; + +const items: PopularHotTakes[] = [ + { + score: 1, + hotTake: { + id: 'p-1', + title: 'Strict mode everywhere', + subtitle: null, + emoji: '๐Ÿงจ', + }, + user: { username: 'spicydev' }, + }, +]; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + shareEnabledMock.mockReturnValue(false); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); + +const renderList = (): RenderResult => + render( + + + , + ); + +describe('PopularHotTakesList', () => { + it('keeps the stock heading markup when the flag is off', () => { + renderList(); + + const heading = screen.getByRole('heading', { level: 3 }); + expect(heading).toHaveClass('mb-2'); + // No wrapper row: the heading still sits directly above the list. + expect(heading.nextElementSibling?.tagName).toBe('OL'); + expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument(); + }); + + it('renders exactly one header share when the flag is on', () => { + shareEnabledMock.mockReturnValue(true); + renderList(); + + const heading = screen.getByRole('heading', { level: 3 }); + expect(heading).not.toHaveClass('mb-2'); + expect(heading.parentElement).toHaveClass('justify-between'); + expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1); + }); + + it('keeps rows deep-linking to the hot-takes section of the owner profile', () => { + shareEnabledMock.mockReturnValue(true); + renderList(); + + expect( + screen.getByRole('link', { name: /Strict mode everywhere/ }), + ).toHaveAttribute('href', '/spicydev#hot-takes'); + }); +}); diff --git a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx index 044c5e36bf8..443a66c0c5c 100644 --- a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx @@ -11,6 +11,10 @@ import { TypographyType, } from '../../typography/Typography'; import { webappUrl } from '../../../lib/constants'; +import { getHotTakesProfileUrl } from '../../../features/profile/components/hotTakes/common'; +import { HotTakeShareControl } from '../../../features/profile/components/hotTakes/HotTakeShareButton'; +import { Origin } from '../../../lib/log'; +import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled'; export type PopularHotTakes = { score: number; @@ -22,13 +26,28 @@ export function PopularHotTakesList({ items, ...props }: CommonLeaderboardProps): ReactElement { + const isShareEnabled = useHotTakeShareEnabled(); + // The custom header must mirror the container's default heading markup so + // flag-off (no header passed) and flag-on only differ by the share control. + const header = isShareEnabled ? ( +
+

{props.containerProps.title}

+ +
+ ) : undefined; + return ( - + {items?.map(({ hotTake, score, user }) => { return ( diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index 50d99767be4..d3e9ed24659 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { HotTake } from '../../../graphql/user/userHotTake'; import { useDiscoverHotTakes } from '../../../hooks/useDiscoverHotTakes'; import { useVoteHotTake } from '../../../hooks/vote/useVoteHotTake'; import { useLogContext } from '../../../contexts/LogContext'; import { useAuthContext } from '../../../contexts/AuthContext'; +import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled'; import { LogEvent, Origin } from '../../../lib/log'; import HotAndColdModal from './HotAndColdModal'; @@ -30,10 +32,15 @@ jest.mock('../../../hooks/useRequestProtocol', () => ({ useRequestProtocol: () => ({ isCompanion: false }), })); +jest.mock('../../../hooks/useHotTakeShareEnabled', () => ({ + useHotTakeShareEnabled: jest.fn(), +})); + const mockedUseDiscoverHotTakes = useDiscoverHotTakes as jest.Mock; const mockedUseVoteHotTake = useVoteHotTake as jest.Mock; const mockedUseLogContext = useLogContext as jest.Mock; const mockedUseAuthContext = useAuthContext as jest.Mock; +const mockedUseHotTakeShareEnabled = useHotTakeShareEnabled as jest.Mock; const createHotTake = (id = 'take-1'): HotTake => ({ id, @@ -46,13 +53,33 @@ const createHotTake = (id = 'take-1'): HotTake => ({ upvoted: false, }); +const createTakeAuthor = (): HotTake['user'] => + ({ + id: 'user-2', + name: 'Spicy Dev', + username: 'spicydev', + image: 'https://daily.dev/avatar.png', + createdAt: '2026-01-01T00:00:00.000Z', + reputation: 42, + permalink: '/spicydev', + companies: [], + isPlus: false, + } as HotTake['user']); + const renderComponent = (onRequestClose = jest.fn()) => { + // The flag-on share control renders the real ShareActions, which needs a + // query client for link shortening. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( - , + + + , ); return { onRequestClose }; @@ -78,6 +105,7 @@ describe('HotAndColdModal', () => { mockedUseAuthContext.mockReturnValue({ user: { username: 'tester' }, }); + mockedUseHotTakeShareEnabled.mockReturnValue(false); mockedUseDiscoverHotTakes.mockReturnValue({ hotTakes: [createHotTake()], currentTake: createHotTake(), @@ -371,4 +399,55 @@ describe('HotAndColdModal', () => { ).toBeVisible(); expect(screen.getAllByText('Starter feed ready')[0]).toBeVisible(); }); + + describe('card share', () => { + const shareLabel = 'Share "Type safety everywhere"'; + + it('renders exactly one share control, on the top card only', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(true); + const currentTake = { ...createHotTake('top'), user: createTakeAuthor() }; + const nextTake = { ...createHotTake('next'), user: createTakeAuthor() }; + + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [currentTake, nextTake], + currentTake, + nextTake, + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1); + // The voting affordances are untouched. + expect( + screen.getByRole('button', { name: 'Hot take - upvote' }), + ).toBeVisible(); + }); + + it('renders no share control for takes without an author', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(true); + + renderComponent(); + + expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument(); + }); + + it('renders no share control when the flag is off', () => { + const currentTake = { ...createHotTake('top'), user: createTakeAuthor() }; + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [currentTake], + currentTake, + nextTake: null, + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 51f9d8628af..99a3c8283d0 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -32,7 +32,12 @@ import { PlusUserBadge } from '../../PlusUserBadge'; import { Loader } from '../../Loader'; import LogoIcon from '../../../svg/LogoIcon'; import type { HotTake } from '../../../graphql/user/userHotTake'; -import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common'; +import { + getAddHotTakeProfileUrl, + getHotTakeShareText, + getHotTakesProfileUrl, +} from '../../../features/profile/components/hotTakes/common'; +import { HotTakeShareButton } from '../../../features/profile/components/hotTakes/HotTakeShareButton'; const SWIPE_THRESHOLD = 80; const ONBOARDING_INTRO_INTERESTING_OFFSET = 56; @@ -843,7 +848,9 @@ const SLEEP_BUBBLES: ReadonlyArray<{ { left: '88%', bottom: '8%', size: 8, delay: 0.45, duration: 2.5 }, ]; -const HotTakeCard = ({ +// Exported for Storybook: the swipeable card is only reachable behind live +// discover data inside the modal. +export const HotTakeCard = ({ hotTake, isTop, offset, @@ -1376,6 +1383,24 @@ const HotTakeCard = ({ )} + + {/* Rendered last + explicit z so it stays clickable above the swipe + effect overlays; only the top card gets a live control. */} + {isTop && hotTake.user?.username && ( +
+ +
+ )} ); }; diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.spec.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.spec.tsx new file mode 100644 index 00000000000..f88f462e97d --- /dev/null +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { HotTake } from '../../../../graphql/user/userHotTake'; +import { HotTakeItem } from './HotTakeItem'; +import { TestBootProvider } from '../../../../../__tests__/helpers/boot'; +import loggedUser from '../../../../../__tests__/fixture/loggedUser'; +import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; +import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled'; + +jest.mock('../../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../../../hooks/useHotTakeShareEnabled', () => ({ + useHotTakeShareEnabled: jest.fn(), +})); + +const engagementBarMock = useEngagementBarV2 as jest.Mock; +const shareEnabledMock = useHotTakeShareEnabled as jest.Mock; + +const item: HotTake = { + id: 'take-1', + emoji: '๐Ÿ”ฅ', + title: 'Small PRs or bust', + subtitle: 'Review time is a feature', + position: 1, + createdAt: '2026-01-01T00:00:00.000Z', + upvotes: 3, + upvoted: false, +}; + +const shareLabel = 'Share "Small PRs or bust"'; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + engagementBarMock.mockReturnValue(false); + shareEnabledMock.mockReturnValue(true); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); + +const renderItem = ( + props: Partial[0]> = {}, +): RenderResult => + render( + + + , + ); + +describe.each([ + ['V1', false], + ['V2', true], +])('HotTakeItem %s share control', (_, isV2) => { + beforeEach(() => engagementBarMock.mockReturnValue(isV2)); + + it('renders exactly one share control next to the existing actions', () => { + renderItem(); + + expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1); + // The upvote affordance is untouched. + expect(screen.getByLabelText(/upvote/i)).toBeInTheDocument(); + }); + + it('renders no share control without an owner username', () => { + renderItem({ ownerUsername: undefined }); + + expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument(); + }); + + it('is byte-identical to the pre-share markup when the flag is off', () => { + shareEnabledMock.mockReturnValue(false); + const { container: flagOff } = renderItem(); + // Rendering without the new prop is the exact markup this PR branched off. + const { container: baseline } = renderItem({ ownerUsername: undefined }); + + expect(screen.queryAllByLabelText(shareLabel)).toHaveLength(0); + expect(flagOff.innerHTML).toBe(baseline.innerHTML); + }); +}); diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 5e6a7bab409..dd7e209c631 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -20,10 +20,15 @@ import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButto import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; import { HotTakeItem as HotTakeItemV2 } from './HotTakeItem.v2'; +import { HotTakeShareButton } from './HotTakeShareButton'; +import { Origin } from '../../../../lib/log'; +import { getHotTakeShareText, getHotTakesProfileUrl } from './common'; interface HotTakeItemProps { item: HotTake; isOwner: boolean; + /** When set, a flag-gated share control is rendered in the action area. */ + ownerUsername?: string; onEdit?: (item: HotTake) => void; onDelete?: (item: HotTake) => void; onUpvoteClick?: (item: HotTake) => void; @@ -32,6 +37,7 @@ interface HotTakeItemProps { function HotTakeItemV1({ item, isOwner, + ownerUsername, onEdit, onDelete, onUpvoteClick, @@ -91,6 +97,16 @@ function HotTakeItemV1({ )} )} + {ownerUsername && ( + + )} {onUpvoteClick && ( void; onDelete?: (item: HotTake) => void; onUpvoteClick?: (item: HotTake) => void; @@ -28,6 +33,7 @@ interface HotTakeItemProps { export function HotTakeItem({ item, isOwner, + ownerUsername, onEdit, onDelete, onUpvoteClick, @@ -87,6 +93,16 @@ export function HotTakeItem({ )} )} + {ownerUsername && ( + + )} {onUpvoteClick && ( ({ + ...jest.requireActual('../../../../lib/constants'), + webappUrl: 'https://app.daily.dev/', +})); + +jest.mock('../../../../hooks/useHotTakeShareEnabled', () => ({ + useHotTakeShareEnabled: jest.fn(), +})); + +jest.mock('../../../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +jest.mock('../../../../hooks/useToastNotification', () => ({ + ...jest.requireActual('../../../../hooks/useToastNotification'), + useToastNotification: jest.fn(), +})); + +const shareEnabledMock = useHotTakeShareEnabled as jest.Mock; +const viewSizeMock = useViewSize as jest.Mock; +const toastMock = useToastNotification as jest.Mock; +const displayToast = jest.fn(); +const logEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +const link = getHotTakesProfileUrl('spicydev'); +const text = getHotTakeShareText({ + title: 'Small PRs or bust', + username: 'spicydev', +}); +const shortLink = 'https://dly.to/take'; + +let client: QueryClient; + +beforeEach(() => { + jest.clearAllMocks(); + shareEnabledMock.mockReturnValue(true); + viewSizeMock.mockReturnValue(true); // laptop + toastMock.mockReturnValue({ displayToast, dismissToast: jest.fn() }); + Object.assign(navigator, { clipboard: { writeText } }); + // Not mobile by default: no native share detection. + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + delete (navigator as { share?: unknown }).share; + + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { queryKey } = getShortLinkProps( + link, + ReferralCampaignKey.Generic, + loggedUser, + ); + client.setQueryData(queryKey, shortLink); +}); + +const renderButton = (): RenderResult => + render( + + + , + ); + +describe('HotTakeShareButton', () => { + it('builds a deep link to the hot-takes section of the owner profile', () => { + expect(link).toBe('https://app.daily.dev/spicydev#hot-takes'); + expect(text).toBe('Hot take: "Small PRs or bust" โ€” @spicydev on daily.dev'); + }); + + it('copies the deep link, shows the toast and logs one ShareLog event', async () => { + renderButton(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share "Small PRs or bust"')); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortLink)); + expect(displayToast).toHaveBeenCalledWith( + 'โœ… Copied link to clipboard', + expect.anything(), + ); + expect(logEvent).toHaveBeenCalledTimes(1); + const [payload] = logEvent.mock.calls[0]; + expect(payload).toMatchObject({ + event_name: LogEvent.ShareLog, + target_id: 'take-1', + }); + expect(JSON.parse(payload.extra)).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.HotTakeList, + }); + }); + + it('opens the native share sheet on a single mobile tap', async () => { + viewSizeMock.mockReturnValue(false); // mobile viewport + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 2, + configurable: true, + }); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share }); + + renderButton(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share "Small PRs or bust"')); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ text: `${text}\n${shortLink}` }), + ); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(JSON.parse(logEvent.mock.calls[0][0].extra)).toMatchObject({ + provider: ShareProvider.Native, + origin: Origin.HotTakeList, + }); + }); + + it('renders nothing when the flag is off', () => { + shareEnabledMock.mockReturnValue(false); + const { container } = renderButton(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx new file mode 100644 index 00000000000..7bd99a52b4c --- /dev/null +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx @@ -0,0 +1,76 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ButtonSize } from '../../../../components/buttons/Button'; +import { ShareActions } from '../../../../components/share/ShareActions'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled'; +import { ReferralCampaignKey } from '../../../../lib/referral'; +import type { Origin } from '../../../../lib/log'; +import { LogEvent } from '../../../../lib/log'; + +// One `ShareLog` event for every hot-take surface, with the surface carried by +// an `Origin` enum member โ€” the same origins the neighbouring vote events in +// these components already use. No per-surface event names, no free-string +// surface vocabulary shadowing the enum. +export type HotTakeShareOrigin = + | Origin.HotTakeList + | Origin.HotAndCold + | Origin.PopularHotTakes; + +export interface HotTakeShareControlProps { + link: string; + text: string; + /** Tooltip + accessible name of the icon trigger. */ + label: string; + origin: HotTakeShareOrigin; + /** Hot take id for single-take shares, owner id for section shares. */ + targetId?: string; + buttonSize?: ButtonSize; + className?: string; +} + +// Presentation only โ€” renders unconditionally, for surfaces that already +// checked the flag themselves (and for Storybook). Product surfaces that +// don't should use `HotTakeShareButton`. +export function HotTakeShareControl({ + link, + text, + label, + origin, + targetId, + buttonSize, + className, +}: HotTakeShareControlProps): ReactElement { + const { logEvent } = useLogContext(); + + return ( + + logEvent({ + event_name: LogEvent.ShareLog, + target_id: targetId, + extra: JSON.stringify({ provider, origin }), + }) + } + /> + ); +} + +export function HotTakeShareButton( + props: HotTakeShareControlProps, +): ReactElement | null { + const isEnabled = useHotTakeShareEnabled(); + + if (!isEnabled) { + return null; + } + + return ; +} diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx index af91017e709..fe7eebb6314 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; +import { QueryClient } from '@tanstack/react-query'; import type { PublicProfile } from '../../../../lib/user'; import type { HotTake } from '../../../../graphql/user/userHotTake'; import { useHotTakes } from '../../hooks/useHotTakes'; @@ -9,8 +10,11 @@ import { useToastNotification } from '../../../../hooks/useToastNotification'; import { usePrompt } from '../../../../hooks/usePrompt'; import { useVoteHotTake } from '../../../../hooks/vote/useVoteHotTake'; import { useLogContext } from '../../../../contexts/LogContext'; +import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled'; import { LogEvent } from '../../../../lib/log'; import { ProfileUserHotTakes } from './ProfileUserHotTakes'; +import { TestBootProvider } from '../../../../../__tests__/helpers/boot'; +import loggedUser from '../../../../../__tests__/fixture/loggedUser'; jest.mock('../../hooks/useHotTakes', () => ({ HOT_TAKE_LIMIT_REACHED_MESSAGE: @@ -19,6 +23,7 @@ jest.mock('../../hooks/useHotTakes', () => ({ })); jest.mock('../../../../hooks/useToastNotification', () => ({ + ...jest.requireActual('../../../../hooks/useToastNotification'), useToastNotification: jest.fn(), })); @@ -45,12 +50,17 @@ jest.mock('./HotTakeItem', () => ({ ), })); +jest.mock('../../../../hooks/useHotTakeShareEnabled', () => ({ + useHotTakeShareEnabled: jest.fn(), +})); + const mockedUseRouter = useRouter as jest.Mock; const mockedUseHotTakes = useHotTakes as jest.Mock; const mockedUseToastNotification = useToastNotification as jest.Mock; const mockedUsePrompt = usePrompt as jest.Mock; const mockedUseVoteHotTake = useVoteHotTake as jest.Mock; const mockedUseLogContext = useLogContext as jest.Mock; +const mockedUseHotTakeShareEnabled = useHotTakeShareEnabled as jest.Mock; const user: PublicProfile = { id: 'user-1', @@ -107,6 +117,7 @@ describe('ProfileUserHotTakes', () => { mockedUseLogContext.mockReturnValue({ logEvent, }); + mockedUseHotTakeShareEnabled.mockReturnValue(false); mockedUseHotTakes.mockReturnValue({ hotTakes: [], isOwner: true, @@ -168,4 +179,85 @@ describe('ProfileUserHotTakes', () => { shallow: true, }); }); + + describe('header share', () => { + const mockTakes = (overrides: Record = {}) => + mockedUseHotTakes.mockReturnValue({ + hotTakes: [createHotTake(1)], + isOwner: true, + canAddMore: true, + isLoading: false, + add: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + ...overrides, + }); + + // The flag-on path renders the real ShareActions, which needs the query + // client and auth contexts the rest of this spec gets away without. + const renderWithProviders = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); + }; + + it('shows exactly one share control next to the Add button when enabled', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(true); + mockTakes(); + + renderWithProviders(); + + const share = screen.getAllByLabelText('Share hot takes'); + expect(share).toHaveLength(1); + const addButton = screen.getByRole('button', { name: 'Add' }); + // Share and Add sit in one grouping row on the header's right side. + expect(share[0].closest('div')).toBe(addButton.closest('div')); + }); + + it('shows the share control to visitors without owner actions', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(true); + mockTakes({ isOwner: false, canAddMore: false }); + + renderWithProviders(); + + expect(screen.getByLabelText('Share hot takes')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add' }), + ).not.toBeInTheDocument(); + }); + + it('hides the share control while the section is empty', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(true); + mockTakes({ hotTakes: [] }); + + renderWithProviders(); + + expect( + screen.queryByLabelText('Share hot takes'), + ).not.toBeInTheDocument(); + // Only evaluated once there is something to share. + expect(mockedUseHotTakeShareEnabled).toHaveBeenCalledWith(false); + }); + + it('keeps the pre-share header markup when the flag is off', () => { + mockedUseHotTakeShareEnabled.mockReturnValue(false); + mockTakes(); + + renderWithProviders(); + + expect( + screen.queryByLabelText('Share hot takes'), + ).not.toBeInTheDocument(); + // The Add button stays the heading's direct sibling โ€” no wrapper row. + const heading = screen.getByText('Hot Takes'); + expect(heading.nextElementSibling).toBe( + screen.getByRole('button', { name: 'Add' }), + ); + }); + }); }); diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx index 71725b706b7..475388aa347 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx @@ -29,10 +29,13 @@ import { useVoteHotTake } from '../../../../hooks/vote/useVoteHotTake'; import { useLogContext } from '../../../../contexts/LogContext'; import { LogEvent, Origin } from '../../../../lib/log'; import { + getHotTakesProfileUrl, HOT_TAKES_ANCHOR, isOpenAddHotTakeQuery, OPEN_ADD_HOT_TAKE_QUERY_PARAM, } from './common'; +import { HotTakeShareControl } from './HotTakeShareButton'; +import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled'; interface ProfileUserHotTakesProps { user: PublicProfile; @@ -180,11 +183,26 @@ export function ProfileUserHotTakes({ ); const hasItems = hotTakes.length > 0; + const ownerUsername = user.username; + // Sharing an empty section is pointless, so the flag is only evaluated (and + // the control only rendered) once there is at least one take. + const isShareEnabled = useHotTakeShareEnabled(hasItems) && hasItems; if (!hasItems && !isOwner) { return null; } + const addButton = isOwner && canAddMore && ( + + ); + return (
{/* eslint-disable-next-line jsx-a11y/anchor-has-content */} @@ -197,15 +215,24 @@ export function ProfileUserHotTakes({ > Hot Takes - {isOwner && canAddMore && ( - + {isShareEnabled && ownerUsername ? ( +
+ + {addButton} +
+ ) : ( + addButton )}
{hasItems ? ( @@ -215,6 +242,7 @@ export function ProfileUserHotTakes({ key={item.id} item={item} isOwner={isOwner} + ownerUsername={user.username} onEdit={handleEdit} onDelete={handleDelete} onUpvoteClick={handleUpvote} diff --git a/packages/shared/src/features/profile/components/hotTakes/common.ts b/packages/shared/src/features/profile/components/hotTakes/common.ts index a788264f847..3d0d61b8a52 100644 --- a/packages/shared/src/features/profile/components/hotTakes/common.ts +++ b/packages/shared/src/features/profile/components/hotTakes/common.ts @@ -7,6 +7,24 @@ export const OPEN_ADD_HOT_TAKE_QUERY_VALUE = '1'; export const getAddHotTakeProfileUrl = (username: string): string => `${webappUrl}${username}?${OPEN_ADD_HOT_TAKE_QUERY_PARAM}=${OPEN_ADD_HOT_TAKE_QUERY_VALUE}#${HOT_TAKES_ANCHOR}`; +// View-only deep link (no `addHotTake` query, which would pop the add modal for +// owners): a shared link lands on the hot-takes section of the owner's profile. +export const getHotTakesProfileUrl = (username: string): string => + `${webappUrl}${username}#${HOT_TAKES_ANCHOR}`; + +// Pre-filled share text quotes the take itself so the share reads like +// something a dev would post, not like a product template. +export const getHotTakeShareText = ({ + title, + username, +}: { + title: string; + username?: string; +}): string => + username + ? `Hot take: "${title}" โ€” @${username} on daily.dev` + : `Hot take: "${title}" โ€” on daily.dev`; + export const isOpenAddHotTakeQuery = ( value: string | string[] | undefined, ): boolean => diff --git a/packages/shared/src/hooks/useHotTakeShareEnabled.ts b/packages/shared/src/hooks/useHotTakeShareEnabled.ts new file mode 100644 index 00000000000..45ea42b2489 --- /dev/null +++ b/packages/shared/src/hooks/useHotTakeShareEnabled.ts @@ -0,0 +1,17 @@ +import { featureShareHotTakes } from '../lib/featureManagement'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useSharingVisibility } from './useSharingVisibility'; + +// Gate for every hot-take share affordance (profile list items and header, the +// Hot & Cold modal card, the popular hot takes leaderboard). The per-topic flag +// is only evaluated once the sharing-visibility master switch is on, so control +// users never get bucketed into the experiment. +export const useHotTakeShareEnabled = (shouldEvaluate = true): boolean => { + const { isEnabled: isSharingEnabled } = useSharingVisibility(shouldEvaluate); + const { value } = useConditionalFeature({ + feature: featureShareHotTakes, + shouldEvaluate: isSharingEnabled, + }); + + return isSharingEnabled && value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..9e8a923f04b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,9 @@ 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); + +// Adds share/copy affordances to the hot-take surfaces: profile list items and +// the section header, the Hot & Cold discovery modal card, and the popular hot +// takes leaderboard card. Gated together with the sharing-visibility master +// switch. Keep the default `false` โ€” GrowthBook ramps it. +export const featureShareHotTakes = new Feature('share_hot_takes', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa61..4cd3c53a40c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,9 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + // Popular hot-takes leaderboard card; the other hot-take share surfaces + // reuse the existing HotTakeList / HotAndCold origins. + PopularHotTakes = 'popular hot takes', } export enum LogEvent { diff --git a/packages/storybook/stories/components/HotTakeShare.stories.tsx b/packages/storybook/stories/components/HotTakeShare.stories.tsx new file mode 100644 index 00000000000..5480e43ae44 --- /dev/null +++ b/packages/storybook/stories/components/HotTakeShare.stories.tsx @@ -0,0 +1,251 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fn } from 'storybook/test'; +import { HotTakeItem } from '@dailydotdev/shared/src/features/profile/components/hotTakes/HotTakeItem'; +import { HotTakeCard } from '@dailydotdev/shared/src/components/modals/hotTakes/HotAndColdModal'; +import { PopularHotTakesList } from '@dailydotdev/shared/src/components/cards/Leaderboard/PopularHotTakesList'; +import type { HotTake } from '@dailydotdev/shared/src/graphql/user/userHotTake'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + FeaturesReadyContext, + GrowthBookProvider, +} from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; + +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; + +const hotTake: HotTake = { + id: 'take-1', + emoji: '๐Ÿ”ฅ', + title: 'Code reviews longer than 20 minutes are a management failure', + subtitle: 'Small PRs or bust', + position: 1, + createdAt: '2026-01-01T00:00:00.000Z', + upvotes: 42, + upvoted: false, +}; + +const modalTake: HotTake = { + ...hotTake, + id: 'take-2', + user: { + id: '2', + name: 'Spicy Dev', + username: 'spicydev', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + permalink: 'https://daily.dev/spicydev', + bio: '', + createdAt: '2024-01-01T00:00:00.000Z', + reputation: 1337, + companies: [], + isPlus: false, + } as unknown as HotTake['user'], +}; + +const popularItems = [ + { + score: 1, + hotTake: { + id: 'p-1', + title: 'TypeScript strict mode should be the default everywhere', + subtitle: 'by @spicydev', + emoji: '๐Ÿงจ', + }, + user: { username: 'spicydev' }, + }, + { + score: 2, + hotTake: { + id: 'p-2', + title: 'Monorepos are a people problem, not a tooling problem', + subtitle: null, + emoji: '๐ŸŒถ๏ธ', + }, + user: { username: 'testuser' }, + }, +]; + +// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` +// coerces every falsy default to the truthy string `'control'`, so a flag can't +// evaluate as `false` here. Flag-off is therefore simulated by holding the +// features context "not ready" โ€” the exact path `useConditionalFeature` takes +// to fall back to the (false) default. Jest is the real flag-off guarantee. +const withProviders = + (enabled: boolean, user: LoggedUser | undefined = mockUser) => + (Story: React.ComponentType): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const LogContext = getLogContextStatic(); + + return ( + + + + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + feature.defaultValue as any, + }} + > + false, + }} + > +
+ +
+
+
+
+
+
+ ); + }; + +const meta: Meta = { + title: 'Components/Share/HotTakeShare', + parameters: { + docs: { + description: { + component: + 'Share affordances on the hot-take surfaces (`share_hot_takes` + the `sharing_visibility` master flag): profile list item, profile section header, the Hot & Cold modal card, and the popular hot takes leaderboard. All share the one `HotTakeShareControl`, deep-linking to the hot-takes section of the owner profile.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Flag on โ€” profile list item with the share control in the action area. All +// boolean flags read truthy under the Storybook GrowthBook mock, so the item +// wrapper resolves to the V2 engagement-bar variant here. +export const ProfileItem: Story = { + decorators: [withProviders(true)], + render: () => ( + + ), +}; + +// V1 variant of the item: the engagement-bar flag is only evaluated for +// authenticated users, so rendering without a user keeps V1 while the share +// flag (evaluated regardless) stays on. +export const ProfileItemV1: Story = { + decorators: [withProviders(true, undefined)], + render: () => ( + + ), +}; + +// Flag off โ€” must render exactly what ships today (no share control). +export const ProfileItemControl: Story = { + decorators: [withProviders(false)], + render: () => ( + + ), +}; + +// The Hot & Cold modal card with the share control pinned top-right. Only the +// top card of the swipe stack gets a live control. +export const ModalCard: Story = { + decorators: [withProviders(true)], + render: () => ( +
+ +
+ ), +}; + +// The popular hot takes leaderboard card with a header share for the board. +export const PopularList: Story = { + decorators: [withProviders(true)], + render: () => ( + + ), +}; + +// Flag off โ€” the leaderboard card keeps its stock heading, byte-identical to +// the version without this PR. +export const PopularListControl: Story = { + decorators: [withProviders(false)], + render: () => ( + + ), +};