Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
039f0f7
docs(snapshot): document hot take and reading history share placements
tomeredlich Sep 2, 2026
0a09685
docs(snapshot): cut the page to the placements it argues for
tomeredlich Sep 3, 2026
802cad4
feat(history): copy a post link from the reading history row
tomeredlich Sep 3, 2026
54adb9f
feat(share): capture a surface as a shareable image
tomeredlich Sep 6, 2026
11bba0b
feat(hot-takes): snapshot a hot take from the modal and the profile list
tomeredlich Sep 6, 2026
813dc10
fix(snapshot): capture the designed hot-take card, not the live row
tomeredlich Sep 6, 2026
a478789
Merge branch 'claude/snapshot-hot-takes-history' of github.com:dailyd…
tomeredlich Sep 6, 2026
5f58d67
style(snapshot): resync the hot-take card with its design
tomeredlich Sep 8, 2026
c9ee3d5
Merge branch 'main' into claude/snapshot-hot-take-placements
tomeredlich Sep 9, 2026
3a9ac8c
Merge branch 'main' into claude/snapshot-hot-take-placements
idoshamun Sep 10, 2026
ac303bf
fix(snapshot): port the hot take snapshot onto main's armed card
idoshamun Sep 10, 2026
20ba5b1
fix(history): log the reading history copy link and copy the tracked …
idoshamun Sep 10, 2026
8ce71ee
chore(storybook): drop the hot takes and history mock page
idoshamun Sep 10, 2026
869d2f5
Merge remote-tracking branch 'origin/main' into qa-6610
idoshamun Sep 10, 2026
38ecf32
feat(snapshot): credit the author on the hot take image
idoshamun Sep 10, 2026
86cf54b
fix(snapshot): keep the button filled while it captures
idoshamun Sep 10, 2026
155964e
fix(hot-takes): do not swipe a card from its snapshot button
idoshamun Sep 10, 2026
86c8f3f
Merge remote-tracking branch 'origin/main' into qa-6610
idoshamun Sep 10, 2026
07f3c8a
Merge remote-tracking branch 'origin/main' into claude/snapshot-hot-t…
tomeredlich Sep 14, 2026
d9f5c02
Merge branch 'main' into claude/snapshot-hot-take-placements
idoshamun Sep 16, 2026
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
41 changes: 40 additions & 1 deletion packages/shared/src/components/history/ReadingHistory.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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(
<TestBootProvider client={new QueryClient()} log={{ logEvent }}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={onRowClick}>
<PostItemCard
postItem={defaultHistory}
logOrigin={Origin.History}
showCopyLink
/>
</div>
</TestBootProvider>,
);

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];
Expand Down
58 changes: 32 additions & 26 deletions packages/shared/src/components/history/ReadingHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactElement[]>(
(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(
<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
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
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/components/imageShare/SnapshotButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -115,7 +118,15 @@ export function SnapshotButton({
setIsCapturing(false);
}
},
[captureOptions, displayToast, filename, onCapture, onResult, target],
[
captureOptions,
displayToast,
filename,
isCapturing,
onCapture,
onResult,
target,
],
);

return (
Expand All @@ -133,7 +144,6 @@ export function SnapshotButton({
size={size}
variant={variant}
loading={isCapturing}
disabled={isCapturing}
icon={<SnapshotIcon />}
onClick={onSnapshot}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,11 +49,13 @@ const createHotTake = (id = 'take-1'): HotTake => ({

const renderComponent = (onRequestClose = jest.fn()) => {
render(
<HotAndColdModal
isOpen
onRequestClose={onRequestClose}
ariaHideApp={false}
/>,
<QueryClientProvider client={new QueryClient()}>
<HotAndColdModal
isOpen
onRequestClose={onRequestClose}
ariaHideApp={false}
/>
</QueryClientProvider>,
);

return { onRequestClose };
Expand Down Expand Up @@ -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'),
Expand Down
41 changes: 28 additions & 13 deletions packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1324,18 +1325,27 @@ const HotTakeCard = ({
</Typography>
)}

{hotTake.upvotes > 0 && (
<div className="flex items-center gap-1 rounded-10 bg-surface-hover px-3 py-1">
<HotIcon className="text-accent-cabbage-default" />
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Secondary}
bold
>
{hotTake.upvotes}
</Typography>
</div>
)}
<div className="flex items-center gap-2">
{hotTake.upvotes > 0 && (
<div className="flex items-center gap-1 rounded-10 bg-surface-hover px-3 py-1">
<HotIcon className="text-accent-cabbage-default" />
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Secondary}
bold
>
{hotTake.upvotes}
</Typography>
</div>
)}
{isTop && (
<HotTakeSnapshotButton
hotTake={hotTake}
origin={Origin.HotAndCold}
variant={ButtonVariant.Primary}
/>
)}
</div>
</div>

{hotTake.user && (
Expand Down Expand Up @@ -1716,6 +1726,7 @@ const HotAndColdModal = ({
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [skipDelta, setSkipDelta] = useState(0);
const swipeDeltaYRef = useRef(0);
const swipeStartedOnButtonRef = useRef(false);
const [internalDismissedCardIds, setInternalDismissedCardIds] = useState<
Set<string>
>(() => new Set<string>());
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading