From e742f108e086e8ddadd58524076e1a33f177fd6f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 18:30:46 +0300 Subject: [PATCH 1/3] feat(share): copy link on reading history rows behind share_history Adds an opt-in showCopyLink prop to PostItemCard rendering a visible Copy link action beside the vote buttons (native share sheet on mobile, copy + toast elsewhere), wired on the history page via ReadingHistoryList behind the sharing_visibility master switch + the new share_history flag (both default off, flag-off DOM unchanged). Logs the normalized SharePost event with the existing history origin. Co-Authored-By: Claude Opus 4.8 --- .../history/ReadingHistory.spec.tsx | 149 +++++++++++++++++- .../components/history/ReadingHistoryList.tsx | 71 +++++---- .../src/components/post/PostItemCard.tsx | 47 +++++- packages/shared/src/lib/featureManagement.ts | 6 + .../components/PostItemCard.stories.tsx | 57 +++++++ 5 files changed, 300 insertions(+), 30 deletions(-) create mode 100644 packages/storybook/stories/components/PostItemCard.stories.tsx diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index d25c8bee114..65a87632e9c 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -1,9 +1,16 @@ import React from 'react'; import { subDays } from 'date-fns'; import type { RenderResult } from '@testing-library/react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import nock from 'nock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; import type { PostItemCardProps } from '../post/PostItemCard'; import PostItemCard from '../post/PostItemCard'; import type { ReadHistoryListProps } from './ReadingHistoryList'; @@ -15,10 +22,33 @@ import user from '../../../__tests__/fixture/loggedUser'; import { getLabel } from '../../lib/dateFormat.spec'; import post from '../../../__tests__/fixture/post'; import { SourceType } from '../../graphql/sources'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { + featureShareHistory, + featureSharingVisibility, +} from '../../lib/featureManagement'; +import type { ToastNotification } from '../../hooks/useToastNotification'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import { ShareProvider } from '../../lib/share'; +import { LogEvent, Origin } from '../../lib/log'; +import { shouldUseNativeShare } from '../../lib/func'; + +jest.mock('../../lib/func', () => { + const actual = jest.requireActual('../../lib/func'); + return { + __esModule: true, + ...actual, + shouldUseNativeShare: jest.fn(() => false), + }; +}); + +const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); beforeEach(() => { nock.cleanAll(); jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); }); describe('ReadingHistoryList component', () => { @@ -121,6 +151,82 @@ describe('ReadingHistoryList component', () => { }); await screen.findByText('Save to bookmarks'); }); + + describe('copy link action (share_history)', () => { + const logEvent = jest.fn(); + + const setupWithFlags = ({ + sharingVisibility = true, + shareHistory = true, + } = {}) => { + const client = new QueryClient(); + const gb = new GrowthBook(); + gb.setFeatures({ + [featureSharingVisibility.id]: { defaultValue: sharingVisibility }, + [featureShareHistory.id]: { defaultValue: shareHistory }, + }); + + render( + + + , + ); + + return client; + }; + + it('should not render the copy action when the flags are off', async () => { + renderComponent(); + await screen.findByTestId('post-item-p1'); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + + it('should not render the copy action when only share_history is on (master kill-switch off)', async () => { + setupWithFlags({ sharingVisibility: false, shareHistory: true }); + await screen.findByTestId('post-item-p1'); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + + it('should render one copy action per row when both flags are on', async () => { + setupWithFlags(); + const buttons = await screen.findAllByLabelText('Copy link'); + expect(buttons).toHaveLength( + defaultReadingHistory.pages[0].readHistory.edges.length, + ); + }); + + it('should copy the post link, show a toast and log SharePost on click', async () => { + const client = setupWithFlags(); + const [button] = await screen.findAllByLabelText('Copy link'); + + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + await waitFor(() => { + const toast = client.getQueryData(TOAST_NOTIF_KEY); + expect(toast?.message).toEqual('✅ Copied link to clipboard'); + }); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + target_id: 'p0', + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.History, + }), + }), + ); + }); + }); }); describe('PostItemCard component', () => { @@ -209,6 +315,47 @@ describe('PostItemCard component', () => { }); }); + it('should not render the copy link action unless opted in', async () => { + renderCard({ showVoteActions: true }); + await screen.findByText(postTitle); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + + it('should copy the post link when the copy action is clicked', async () => { + renderCard({ showVoteActions: true, showCopyLink: true }); + const button = await screen.findByLabelText('Copy link'); + + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + defaultHistory.post.commentsPermalink, + ), + ); + }); + + it('should open the native share sheet on mobile instead of copying', async () => { + shouldUseNativeShareMock.mockReturnValueOnce(true); + const share = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { share }); + + renderCard({ showVoteActions: true, showCopyLink: true }); + const button = await screen.findByLabelText('Copy link'); + + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => + expect(share).toHaveBeenCalledWith({ + text: expect.stringContaining(defaultHistory.post.commentsPermalink), + }), + ); + expect(writeText).not.toHaveBeenCalled(); + }); + it('should not bubble vote clicks to parent handlers', async () => { const onClick = jest.fn(); const onKeyDown = jest.fn(); diff --git a/packages/shared/src/components/history/ReadingHistoryList.tsx b/packages/shared/src/components/history/ReadingHistoryList.tsx index cf17c826d9b..47b1a628ddc 100644 --- a/packages/shared/src/components/history/ReadingHistoryList.tsx +++ b/packages/shared/src/components/history/ReadingHistoryList.tsx @@ -7,6 +7,9 @@ import { InfiniteScrollScreenOffset } from '../../hooks/feed/useFeedInfiniteScro import { Origin } from '../../lib/log'; import { DateFormat } from '../utilities'; import type { ReadHistoryInfiniteData } from '../../hooks/useInfiniteReadingHistory'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { useSharingVisibility } from '../../hooks/useSharingVisibility'; +import { featureShareHistory } from '../../lib/featureManagement'; export interface ReadHistoryListProps { data: ReadHistoryInfiniteData; @@ -19,45 +22,59 @@ export default function ReadHistoryList({ onHide, infiniteScrollRef, }: ReadHistoryListProps): ReactElement { + const { isEnabled: isSharingVisible } = useSharingVisibility(); + // Per-surface flag; only evaluated once the master kill-switch is on. + const { value: isShareHistoryEnabled } = useConditionalFeature({ + feature: featureShareHistory, + shouldEvaluate: isSharingVisible, + }); + const showCopyLink = isSharingVisible && isShareHistoryEnabled; + const renderList = useCallback(() => { let currentDate: Date; return data?.pages.map((page, pageIndex) => - page.readHistory.edges.reduce((dom, { node: history }, edgeIndex) => { - const { timestamp } = history; - const date = new Date(timestamp); + page.readHistory.edges.reduce( + (dom, { node: history }, edgeIndex) => { + const { timestamp } = history; + // NaN fallback keeps the pre-strict `new Date(undefined)` behavior + // (Invalid Date) for the never-expected timestamp-less edge. + const date = new Date(timestamp ?? Number.NaN); + + if (!currentDate || !isDateOnlyEqual(currentDate, date)) { + currentDate = date; + dom.push( + , + ); + } + + const indexes = { page: pageIndex, edge: edgeIndex }; - if (!currentDate || !isDateOnlyEqual(currentDate, date)) { - currentDate = date; dom.push( - onHide({ ...params, ...indexes })} + showVoteActions + showCopyLink={showCopyLink} + logOrigin={Origin.History} />, ); - } - - const indexes = { page: pageIndex, edge: edgeIndex }; - - dom.push( - onHide({ ...params, ...indexes })} - showVoteActions - logOrigin={Origin.History} - />, - ); - return dom; - }, []), + return dom; + }, + [], + ), ); // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, onHide]); + }, [data, onHide, showCopyLink]); return (
diff --git a/packages/shared/src/components/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index 4a49c05a0ed..f6d57bc0959 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -5,7 +5,12 @@ import Link from '../utilities/Link'; import type { HidePostItemCardProps } from '../../graphql/users'; import type { PostItem } from '../../graphql/posts'; import { UserVote, isVideoPost } from '../../graphql/posts'; -import { MiniCloseIcon as XIcon, UpvoteIcon, DownvoteIcon } from '../icons'; +import { + MiniCloseIcon as XIcon, + UpvoteIcon, + DownvoteIcon, + CopyIcon, +} from '../icons'; import classed from '../../lib/classed'; import PostMetadata from '../cards/common/PostMetadata'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; @@ -13,7 +18,10 @@ import { Image } from '../image/Image'; import ConditionalWrapper from '../ConditionalWrapper'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { useReadHistoryVotePost } from '../../hooks'; -import { Origin } from '../../lib/log'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { postLogEvent } from '../../lib/feed'; +import { LogEvent, Origin } from '../../lib/log'; +import { Tooltip } from '../tooltip/Tooltip'; import { Button, ButtonColor, @@ -32,6 +40,12 @@ export interface PostItemCardProps { clickable?: boolean; onHide?: (params: HidePostItemCardProps) => Promise; showVoteActions?: boolean; + /** + * Renders a visible "Copy link" action in the button row. Off by default so + * every existing consumer keeps its exact DOM (sharing-visibility surfaces + * opt in explicitly). + */ + showCopyLink?: boolean; logOrigin?: Origin; indexes?: QueryIndexes; } @@ -48,6 +62,7 @@ export default function PostItemCard({ onHide, className, showVoteActions = false, + showCopyLink = false, logOrigin = Origin.Feed, indexes, }: PostItemCardProps): ReactElement { @@ -76,6 +91,18 @@ export default function PostItemCard({ const title = post?.title || post?.sharedPost?.title; + // Single tap: native share sheet on mobile, copy + toast elsewhere. History + // rows are real posts, so the normalized SharePost event applies. A missing + // permalink falls through to useCopyLink's "link is missing" error toast. + const [copyingLink, onShareOrCopyLink] = useShareOrCopyLink({ + link: post.commentsPermalink ?? '', + text: title ?? '', + logObject: (provider) => + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: logOrigin }, + }), + }); + return (
)} + {showButtons && showCopyLink && ( + +