Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 165 additions & 1 deletion packages/shared/src/components/history/ReadingHistory.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(
<TestBootProvider
client={client}
gb={gb}
auth={{ user }}
log={{ logEvent }}
>
<ReadHistoryList {...props} />
</TestBootProvider>,
);

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<ToastNotification>(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', () => {
Expand Down Expand Up @@ -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();
Expand Down
71 changes: 44 additions & 27 deletions packages/shared/src/components/history/ReadingHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ReactElement[]>(
(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(
<DateFormat
key={date.toISOString()}
date={date}
type={TimeFormatType.ReadHistory}
className="my-3 px-6 text-text-tertiary typo-body first:mt-0"
/>,
);
}

const indexes = { page: pageIndex, edge: edgeIndex };

if (!currentDate || !isDateOnlyEqual(currentDate, date)) {
currentDate = date;
dom.push(
<DateFormat
key={date.toISOString()}
date={date}
type={TimeFormatType.ReadHistory}
className="my-3 px-6 text-text-tertiary typo-body first:mt-0"
<PostItemCard
key={`${history.post.id}-${timestamp}`}
postItem={history}
indexes={indexes}
onHide={(params) => onHide({ ...params, ...indexes })}
showVoteActions
showCopyLink={showCopyLink}
logOrigin={Origin.History}
/>,
);
}

const indexes = { page: pageIndex, edge: edgeIndex };

dom.push(
<PostItemCard
key={`${history.post.id}-${timestamp}`}
postItem={history}
indexes={indexes}
onHide={(params) => 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 (
<section className="relative flex flex-col">
Expand Down
60 changes: 58 additions & 2 deletions packages/shared/src/components/post/PostItemCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ 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';
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,
Expand All @@ -32,6 +43,12 @@ export interface PostItemCardProps {
clickable?: boolean;
onHide?: (params: HidePostItemCardProps) => Promise<unknown>;
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;
}
Expand All @@ -48,6 +65,7 @@ export default function PostItemCard({
onHide,
className,
showVoteActions = false,
showCopyLink = false,
logOrigin = Origin.Feed,
indexes,
}: PostItemCardProps): ReactElement {
Expand Down Expand Up @@ -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 (
<article className={classNames(!clickable && classes)}>
Expand Down Expand Up @@ -176,6 +210,28 @@ export default function PostItemCard({
/>
</>
)}
{showButtons && showCopyLink && (
<Tooltip content="Copy link">
<Button
type="button"
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
aria-label="Copy link"
icon={
showCopyIcon ? (
<CopyIcon secondary={copyingLink} />
) : (
<LinkIcon />
)
}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onShareOrCopyLink();
}}
/>
</Tooltip>
)}
{showButtons && !showVoteActions && onHide && (
<Button
size={ButtonSize.Small}
Expand Down
Loading
Loading