From a72f9665f5ebb813e26f933c57a724e0530aa467 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 19:25:36 +0300 Subject: [PATCH 1/2] feat(share): share/copy on hot-take surfaces Share affordances on every hot-take surface, built on the PR 1 ShareActions primitive: HotTakeItem (V1 + V2) action area, the ProfileUserHotTakes section header, the HotAndColdModal top card, and a header share inside PopularHotTakesList (pages/users.tsx untouched). - deep links land on the owner profile's #hot-takes anchor (getHotTakesProfileUrl), pre-filled text quotes the take - one generic ShareLog event with the new Origin.HotTake for all surfaces; extra.surface distinguishes, target_id = take id / owner id - behind share_hot_takes (default false) AND the sharing_visibility master flag via useHotTakeShareEnabled; flag-off is byte-identical (asserted in Jest on every surface) Co-Authored-By: Claude Fable 5 --- .../Leaderboard/PopularHotTakesList.spec.tsx | 79 ++++++ .../cards/Leaderboard/PopularHotTakesList.tsx | 22 +- .../modals/hotTakes/HotAndColdModal.spec.tsx | 89 ++++++- .../modals/hotTakes/HotAndColdModal.tsx | 29 +- .../components/hotTakes/HotTakeItem.spec.tsx | 89 +++++++ .../components/hotTakes/HotTakeItem.tsx | 15 ++ .../components/hotTakes/HotTakeItem.v2.tsx | 15 ++ .../hotTakes/HotTakeShareButton.spec.tsx | 165 ++++++++++++ .../hotTakes/HotTakeShareButton.tsx | 75 ++++++ .../hotTakes/ProfileUserHotTakes.spec.tsx | 92 +++++++ .../hotTakes/ProfileUserHotTakes.tsx | 46 +++- .../profile/components/hotTakes/common.ts | 18 ++ .../src/hooks/useHotTakeShareEnabled.ts | 17 ++ packages/shared/src/lib/featureManagement.ts | 6 + packages/shared/src/lib/log.ts | 2 + .../components/HotTakeShare.stories.tsx | 251 ++++++++++++++++++ 16 files changed, 992 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.spec.tsx create mode 100644 packages/shared/src/features/profile/components/hotTakes/HotTakeItem.spec.tsx create mode 100644 packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.spec.tsx create mode 100644 packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx create mode 100644 packages/shared/src/hooks/useHotTakeShareEnabled.ts create mode 100644 packages/storybook/stories/components/HotTakeShare.stories.tsx 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..a1ecfdde694 100644 --- a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx @@ -11,6 +11,9 @@ 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 { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled'; export type PopularHotTakes = { score: number; @@ -22,13 +25,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..471361905ad 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..9c7892794cd 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -20,10 +20,14 @@ 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 { 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 +36,7 @@ interface HotTakeItemProps { function HotTakeItemV1({ item, isOwner, + ownerUsername, onEdit, onDelete, onUpvoteClick, @@ -91,6 +96,16 @@ function HotTakeItemV1({ )} )} + {ownerUsername && ( + + )} {onUpvoteClick && ( void; onDelete?: (item: HotTake) => void; onUpvoteClick?: (item: HotTake) => void; @@ -28,6 +32,7 @@ interface HotTakeItemProps { export function HotTakeItem({ item, isOwner, + ownerUsername, onEdit, onDelete, onUpvoteClick, @@ -87,6 +92,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.HotTake, + surface: 'profile item', + }); + }); + + 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, + surface: 'profile item', + }); + }); + + 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..df9e644113d --- /dev/null +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx @@ -0,0 +1,75 @@ +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 { LogEvent, Origin } from '../../../../lib/log'; + +// One `ShareLog` event with `Origin.HotTake` for every hot-take surface โ€” +// `surface` in `extra` is the only per-surface distinction (no per-surface +// event names). +export type HotTakeShareSurface = + | 'profile item' + | 'profile header' + | 'hot and cold modal' + | 'popular hot takes'; + +export interface HotTakeShareControlProps { + link: string; + text: string; + /** Tooltip + accessible name of the icon trigger. */ + label: string; + surface: HotTakeShareSurface; + /** 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, + surface, + targetId, + buttonSize, + className, +}: HotTakeShareControlProps): ReactElement { + const { logEvent } = useLogContext(); + + return ( + + logEvent({ + event_name: LogEvent.ShareLog, + target_id: targetId, + extra: JSON.stringify({ provider, origin: Origin.HotTake, surface }), + }) + } + /> + ); +} + +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..0970404aaf2 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..e2b2dfdf199 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,8 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + // One origin for every hot-take share surface; `extra.surface` distinguishes + HotTake = 'hot take', } 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: () => ( + + ), +}; From 766d09e4fc33b85e49a814da570052bff484a745 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 22:57:03 +0300 Subject: [PATCH 2/2] fix(share): carry hot-take share surfaces in Origin enum members Replaces the free-string extra.surface vocabulary that shadowed existing enum values: the modal reuses Origin.HotAndCold, the profile surfaces reuse Origin.HotTakeList (target_id already separates item from header), and the leaderboard card gets a proper PopularHotTakes member in place of HotTake. Co-Authored-By: Claude Opus 4.8 --- .../cards/Leaderboard/PopularHotTakesList.tsx | 3 ++- .../modals/hotTakes/HotAndColdModal.tsx | 2 +- .../components/hotTakes/HotTakeItem.tsx | 3 ++- .../components/hotTakes/HotTakeItem.v2.tsx | 3 ++- .../hotTakes/HotTakeShareButton.spec.tsx | 7 +++--- .../hotTakes/HotTakeShareButton.tsx | 25 ++++++++++--------- .../hotTakes/ProfileUserHotTakes.tsx | 2 +- packages/shared/src/lib/log.ts | 5 ++-- 8 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx index a1ecfdde694..443a66c0c5c 100644 --- a/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/PopularHotTakesList.tsx @@ -13,6 +13,7 @@ import { 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 = { @@ -35,7 +36,7 @@ export function PopularHotTakesList({ link={`${webappUrl}users`} text="The most popular hot takes on daily.dev โ€” developers' spiciest opinions, ranked." label="Share the hot takes leaderboard" - surface="popular hot takes" + origin={Origin.PopularHotTakes} /> ) : undefined; diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 471361905ad..99a3c8283d0 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -1396,7 +1396,7 @@ export const HotTakeCard = ({ })} label={`Share "${hotTake.title}"`} targetId={hotTake.id} - surface="hot and cold modal" + origin={Origin.HotAndCold} buttonSize={ButtonSize.Small} /> diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 9c7892794cd..dd7e209c631 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -21,6 +21,7 @@ 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 { @@ -102,7 +103,7 @@ function HotTakeItemV1({ text={getHotTakeShareText({ title, username: ownerUsername })} label={`Share "${title}"`} targetId={item.id} - surface="profile item" + origin={Origin.HotTakeList} buttonSize={ButtonSize.XSmall} /> )} diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx index 7299fceeee5..e93b7f28264 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx @@ -17,6 +17,7 @@ import { EditIcon, TrashIcon, UpvoteIcon } from '../../../../components/icons'; import { CardAction } from '../../../../components/buttons/CardAction'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { HotTakeShareButton } from './HotTakeShareButton'; +import { Origin } from '../../../../lib/log'; import { getHotTakeShareText, getHotTakesProfileUrl } from './common'; interface HotTakeItemProps { @@ -98,7 +99,7 @@ export function HotTakeItem({ text={getHotTakeShareText({ title, username: ownerUsername })} label={`Share "${title}"`} targetId={item.id} - surface="profile item" + origin={Origin.HotTakeList} buttonSize={ButtonSize.XSmall} /> )} diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.spec.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.spec.tsx index b34bff4d0b0..67f515e1862 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.spec.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.spec.tsx @@ -91,7 +91,7 @@ const renderButton = (): RenderResult => text={text} label='Share "Small PRs or bust"' targetId="take-1" - surface="profile item" + origin={Origin.HotTakeList} /> , ); @@ -126,8 +126,7 @@ describe('HotTakeShareButton', () => { }); expect(JSON.parse(payload.extra)).toEqual({ provider: ShareProvider.CopyLink, - origin: Origin.HotTake, - surface: 'profile item', + origin: Origin.HotTakeList, }); }); @@ -152,7 +151,7 @@ describe('HotTakeShareButton', () => { expect(logEvent).toHaveBeenCalledTimes(1); expect(JSON.parse(logEvent.mock.calls[0][0].extra)).toMatchObject({ provider: ShareProvider.Native, - surface: 'profile item', + origin: Origin.HotTakeList, }); }); diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx index df9e644113d..7bd99a52b4c 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeShareButton.tsx @@ -5,23 +5,24 @@ import { ShareActions } from '../../../../components/share/ShareActions'; import { useLogContext } from '../../../../contexts/LogContext'; import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled'; import { ReferralCampaignKey } from '../../../../lib/referral'; -import { LogEvent, Origin } from '../../../../lib/log'; +import type { Origin } from '../../../../lib/log'; +import { LogEvent } from '../../../../lib/log'; -// One `ShareLog` event with `Origin.HotTake` for every hot-take surface โ€” -// `surface` in `extra` is the only per-surface distinction (no per-surface -// event names). -export type HotTakeShareSurface = - | 'profile item' - | 'profile header' - | 'hot and cold modal' - | 'popular hot takes'; +// 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; - surface: HotTakeShareSurface; + origin: HotTakeShareOrigin; /** Hot take id for single-take shares, owner id for section shares. */ targetId?: string; buttonSize?: ButtonSize; @@ -35,7 +36,7 @@ export function HotTakeShareControl({ link, text, label, - surface, + origin, targetId, buttonSize, className, @@ -55,7 +56,7 @@ export function HotTakeShareControl({ logEvent({ event_name: LogEvent.ShareLog, target_id: targetId, - extra: JSON.stringify({ provider, origin: Origin.HotTake, surface }), + extra: JSON.stringify({ provider, origin }), }) } /> diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx index 0970404aaf2..475388aa347 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx @@ -226,7 +226,7 @@ export function ProfileUserHotTakes({ } label="Share hot takes" targetId={user.id} - surface="profile header" + origin={Origin.HotTakeList} buttonSize={ButtonSize.Small} /> {addButton} diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index e2b2dfdf199..4cd3c53a40c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,8 +108,9 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', - // One origin for every hot-take share surface; `extra.surface` distinguishes - HotTake = 'hot take', + // Popular hot-takes leaderboard card; the other hot-take share surfaces + // reuse the existing HotTakeList / HotAndCold origins. + PopularHotTakes = 'popular hot takes', } export enum LogEvent {