diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index d25c8bee114..c3dce291631 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -1,7 +1,7 @@ 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'; import nock from 'nock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PostItemCardProps } from '../post/PostItemCard'; @@ -15,6 +15,9 @@ 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 { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; beforeEach(() => { nock.cleanAll(); @@ -199,6 +202,42 @@ describe('PostItemCard component', () => { ); }); + it('should copy the post link and log it as a share from history', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const logEvent = jest.fn(); + const onRowClick = jest.fn(); + + render( + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ +
+
, + ); + + fireEvent.click(await screen.findByLabelText('Copy link')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + expect(onRowClick).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + target_id: post.id, + extra: expect.stringContaining( + `"provider":"${ShareProvider.CopyLink}","origin":"${Origin.History}"`, + ), + }), + ); + }); + it('should call onHide on close button clicked', async () => { renderCard({ onHide }); const button = (await screen.findAllByRole('button'))[0]; diff --git a/packages/shared/src/components/history/ReadingHistoryList.tsx b/packages/shared/src/components/history/ReadingHistoryList.tsx index cf17c826d9b..befe9b2b9f2 100644 --- a/packages/shared/src/components/history/ReadingHistoryList.tsx +++ b/packages/shared/src/components/history/ReadingHistoryList.tsx @@ -23,37 +23,43 @@ export default function ReadHistoryList({ 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; + // Optional only because PostItem is shared with surfaces that carry + // no timestamp; every reading-history edge has one. + const date = new Date(timestamp as Date); + + 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 + 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 diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 40a15690dec..a178eec68e5 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -73,6 +73,9 @@ export function SnapshotButton({ // Every placement sits inside a clickable card, row or link. event.preventDefault(); event.stopPropagation(); + if (isCapturing) { + return; + } setIsFlashing(true); flashTimeout.current = setTimeout( () => setIsFlashing(false), @@ -115,7 +118,15 @@ export function SnapshotButton({ setIsCapturing(false); } }, - [captureOptions, displayToast, filename, onCapture, onResult, target], + [ + captureOptions, + displayToast, + filename, + isCapturing, + onCapture, + onResult, + target, + ], ); return ( @@ -133,7 +144,6 @@ export function SnapshotButton({ size={size} variant={variant} loading={isCapturing} - disabled={isCapturing} icon={} onClick={onSnapshot} > diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index 50d99767be4..0f0f9bef6d9 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -1,5 +1,6 @@ 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'; @@ -48,11 +49,13 @@ const createHotTake = (id = 'take-1'): HotTake => ({ const renderComponent = (onRequestClose = jest.fn()) => { render( - , + + + , ); return { onRequestClose }; @@ -253,6 +256,53 @@ describe('HotAndColdModal', () => { expect(onRequestClose).toHaveBeenCalledTimes(1); }); + it('should offer a snapshot on the top card only', () => { + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [createHotTake('top'), createHotTake('behind')], + currentTake: createHotTake('top'), + nextTake: createHotTake('behind'), + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + // The card behind is rendered too, and a second control would capture a + // take the reader has not reached yet. + expect(screen.getAllByLabelText('Snapshot')).toHaveLength(1); + }); + + it('should not swipe the card when a drag starts on the snapshot button', () => { + const currentTake = createHotTake('snapshot-drag'); + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [currentTake], + currentTake, + nextTake: null, + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + const swipeRight = (from: Element) => + act(() => { + fireEvent.touchStart(from, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(from, { touches: [{ clientX: 200, clientY: 0 }] }); + fireEvent.touchEnd(from, { touches: [] }); + }); + + swipeRight(screen.getByLabelText('Snapshot')); + expect(toggleUpvote).not.toHaveBeenCalled(); + + swipeRight(screen.getByText(currentTake.title)); + expect(toggleUpvote).toHaveBeenCalledWith({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + }); + it('should keep subtitle visible even when title is very long', () => { const currentTake = { ...createHotTake('long-text'), diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 51f9d8628af..f6768c04a5c 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -26,6 +26,7 @@ import { TypographyColor, } from '../../typography/Typography'; import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; +import { HotTakeSnapshotButton } from '../../../features/snapshot/HotTakeSnapshotButton'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; @@ -1324,18 +1325,27 @@ const HotTakeCard = ({ )} - {hotTake.upvotes > 0 && ( -
- - - {hotTake.upvotes} - -
- )} +
+ {hotTake.upvotes > 0 && ( +
+ + + {hotTake.upvotes} + +
+ )} + {isTop && ( + + )} +
{hotTake.user && ( @@ -1716,6 +1726,7 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); + const swipeStartedOnButtonRef = useRef(false); const [internalDismissedCardIds, setInternalDismissedCardIds] = useState< Set >(() => new Set()); @@ -2182,8 +2193,12 @@ const HotAndColdModal = ({ }; const handlers = useSwipeable({ + onTouchStartOrOnMouseDown: ({ event }) => { + swipeStartedOnButtonRef.current = + event.target instanceof Element && !!event.target.closest('button'); + }, onSwiping: (e) => { - if (!isAnimating) { + if (!isAnimating && !swipeStartedOnButtonRef.current) { if (isOnboardingMode && e.event.cancelable) { e.event.preventDefault(); } diff --git a/packages/shared/src/components/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index 4a49c05a0ed..0f4955847be 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -6,6 +6,7 @@ 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 { LinkIcon } from '../icons/Link'; import classed from '../../lib/classed'; import PostMetadata from '../cards/common/PostMetadata'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; @@ -13,7 +14,7 @@ import { Image } from '../image/Image'; import ConditionalWrapper from '../ConditionalWrapper'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { useReadHistoryVotePost } from '../../hooks'; -import { Origin } from '../../lib/log'; +import { LogEvent, Origin } from '../../lib/log'; import { Button, ButtonColor, @@ -24,6 +25,13 @@ import { isSourceUserSource } from '../../graphql/sources'; import { ReadingHistoryOptionsMenu } from '../history/ReadingHistoryOptionsMenu'; import type { QueryIndexes } from '../../hooks/useReadingHistory'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { CopyStateIcon } from '../share/CopyStateIcon'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useLogContext } from '../../contexts/LogContext'; +import { postLogEvent } from '../../lib/feed'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ShareProvider } from '../../lib/share'; export interface PostItemCardProps { className?: string; @@ -32,6 +40,7 @@ export interface PostItemCardProps { clickable?: boolean; onHide?: (params: HidePostItemCardProps) => Promise; showVoteActions?: boolean; + showCopyLink?: boolean; logOrigin?: Origin; indexes?: QueryIndexes; } @@ -48,6 +57,7 @@ export default function PostItemCard({ onHide, className, showVoteActions = false, + showCopyLink = false, logOrigin = Origin.Feed, indexes, }: PostItemCardProps): ReactElement { @@ -66,6 +76,23 @@ export default function PostItemCard({ const isUserSource = isSourceUserSource(source); const { toggleUpvote, toggleDownvote } = useReadHistoryVotePost(); + const [linkCopied, copyLink] = useCopyPostLink(); + const { logEvent } = useLogContext(); + + const onCopyLink = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider: ShareProvider.CopyLink, origin: logOrigin }, + }), + ); + copyLink({ + link: post.commentsPermalink, + shorten: true, + cid: ReferralCampaignKey.SharePost, + }); + }; const classes = classNames( 'relative flex w-full flex-row py-3 pl-9 pr-5', @@ -185,6 +212,17 @@ export default function PostItemCard({ onClick={onHideClick} /> )} + {showButtons && showCopyLink && ( + +