diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx
index d25c8bee114..81244be26f3 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,50 @@ 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),
+ };
+});
+
+// The row share passes a referral cid, which routes the link through
+// `useGetShortUrl`. Resolve it to the original URL here so these tests stay
+// about the copy/share flow; short-link resolution is covered by that hook.
+jest.mock('../../hooks/utils/useGetShortUrl', () => {
+ const actual = jest.requireActual('../../hooks/utils/useGetShortUrl');
+ return {
+ __esModule: true,
+ ...actual,
+ useGetShortUrl: () => ({
+ getShortUrl: async (url: string) => url,
+ getTrackedUrl: (url: string) => url,
+ shareLink: '',
+ isLoading: 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 +168,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 +332,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..e7e4cc7acb7 100644
--- a/packages/shared/src/components/post/PostItemCard.tsx
+++ b/packages/shared/src/components/post/PostItemCard.tsx
@@ -5,7 +5,14 @@ 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,
+ LinkIcon,
+} from '../icons';
+import { useShareCopyIcon } from '../../hooks/useShareCopyIcon';
import classed from '../../lib/classed';
import PostMetadata from '../cards/common/PostMetadata';
import { ProfileImageSize, ProfilePicture } from '../ProfilePicture';
@@ -13,7 +20,11 @@ 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 { ReferralCampaignKey } from '../../lib/referral';
+import { Tooltip } from '../tooltip/Tooltip';
import {
Button,
ButtonColor,
@@ -32,6 +43,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 +65,7 @@ export default function PostItemCard({
onHide,
className,
showVoteActions = false,
+ showCopyLink = false,
logOrigin = Origin.Feed,
indexes,
}: PostItemCardProps): ReactElement {
@@ -75,6 +93,22 @@ export default function PostItemCard({
);
const title = post?.title || post?.sharedPost?.title;
+ // The row action copies a URL, so it takes the link glyph in control and
+ // follows the same `share_copy_icon` ramp as the other copy-link surfaces.
+ const showCopyIcon = useShareCopyIcon();
+
+ // 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 ?? '',
+ cid: ReferralCampaignKey.SharePost,
+ logObject: (provider) =>
+ postLogEvent(LogEvent.SharePost, post, {
+ extra: { provider, origin: logOrigin },
+ }),
+ });
return (
@@ -176,6 +210,28 @@ export default function PostItemCard({
/>
>
)}
+ {showButtons && showCopyLink && (
+
+
+ ) : (
+
+ )
+ }
+ onClick={(e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ onShareOrCopyLink();
+ }}
+ />
+
+ )}
{showButtons && !showVoteActions && onHide && (