Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
98d3a33
feat(snapshot): snapshot an expanded highlight on Happening Now
tomeredlich Sep 2, 2026
5fd2384
feat(snapshot): share controls across the Happening Now surface
tomeredlich Sep 3, 2026
da2f4e4
feat(share): confirm a copy on the button that was pressed
tomeredlich Sep 3, 2026
d15341b
feat(snapshot): take the redesigned card from the share-images work
tomeredlich Sep 8, 2026
b8d84b9
Merge branch 'main' into snapshot-happening-now
tomeredlich Sep 9, 2026
8819587
Merge branch 'main' into snapshot-happening-now
idoshamun Sep 10, 2026
1475a5d
refactor(snapshot): build the Happening Now placement on main's snaps…
idoshamun Sep 10, 2026
83c1c0a
fix(highlights): keep the Happening Now headers intact and log their …
idoshamun Sep 10, 2026
00489b1
fix(snapshot): remove the review page, shutter sound and preview-host…
idoshamun Sep 10, 2026
219b3de
chore(storybook): drop the Happening Now surface mockup now that it s…
idoshamun Sep 10, 2026
875f4ee
feat(highlights): ship the Happening Now share controls without a flag
idoshamun Sep 10, 2026
0301b1e
Merge branch 'main' into snapshot-happening-now
idoshamun Sep 10, 2026
56cf893
fix(highlights): copy absolute, tracked links from Happening Now
idoshamun Sep 10, 2026
1ff6c5b
fix(snapshot): credit the source on the Happening Now share cards
idoshamun Sep 10, 2026
f603fa4
Merge branch 'main' into snapshot-happening-now
idoshamun Sep 10, 2026
1a365f9
Merge remote-tracking branch 'origin/main' into snapshot-happening-now
tomeredlich Sep 14, 2026
df29f53
refactor(highlights): build the page share link with getAbsoluteWebap…
tomeredlich Sep 14, 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
174 changes: 168 additions & 6 deletions packages/shared/src/components/cards/highlight/HighlightCards.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { ReactElement } from 'react';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import loggedUser from '../../../../__tests__/fixture/loggedUser';
import { gqlClient } from '../../../graphql/common';
import { LogEvent, Origin, TargetType } from '../../../lib/log';
import { ReferralCampaignKey } from '../../../lib/referral';
import { ShareProvider } from '../../../lib/share';
import type { LoggedUser } from '../../../lib/user';
import { HighlightGrid } from './HighlightGrid';
import { HighlightList } from './HighlightList';

Expand All @@ -16,7 +25,7 @@ const highlights = [
highlightedAt: '2026-04-05T09:00:00.000Z',
post: {
id: 'post-1',
commentsPermalink: '/posts/post-1',
commentsPermalink: 'https://app.daily.dev/posts/post-1',
},
},
{
Expand All @@ -26,14 +35,29 @@ const highlights = [
highlightedAt: '2026-04-05T08:00:00.000Z',
post: {
id: 'post-2',
commentsPermalink: '/posts/post-2',
commentsPermalink: 'https://app.daily.dev/posts/post-2',
},
},
];

const renderCard = (
card: ReactElement,
logEvent = jest.fn(),
user?: LoggedUser,
) =>
render(
<TestBootProvider
auth={{ user }}
client={new QueryClient()}
log={{ logEvent }}
>
{card}
</TestBootProvider>,
);

describe('Highlight cards', () => {
it('should render the grid card with highlight links', () => {
render(<HighlightGrid highlights={highlights} />);
renderCard(<HighlightGrid highlights={highlights} />);

expect(screen.getByText('Happening Now')).toBeInTheDocument();
expect(screen.getByText('The first highlight')).toBeInTheDocument();
Expand All @@ -55,7 +79,7 @@ describe('Highlight cards', () => {
});

it('should render the list card with highlight links', () => {
render(<HighlightList highlights={highlights} />);
renderCard(<HighlightList highlights={highlights} />);

expect(screen.getByText('The first highlight')).toBeInTheDocument();
expect(screen.getByText('The second highlight')).toBeInTheDocument();
Expand All @@ -66,7 +90,7 @@ describe('Highlight cards', () => {
const onHighlightClick = jest.fn();
const onReadAllClick = jest.fn();

render(
renderCard(
<HighlightGrid
highlights={highlights}
onHighlightClick={onHighlightClick}
Expand All @@ -83,3 +107,141 @@ describe('Highlight cards', () => {
expect(onReadAllClick).toHaveBeenCalledTimes(1);
});
});

describe('Highlight card share controls', () => {
const writeText = jest.fn().mockResolvedValue(undefined);

beforeAll(() => {
Object.assign(navigator, { clipboard: { writeText } });
});

beforeEach(() => {
writeText.mockClear();
});

const renderShareable = (logEvent: jest.Mock, onHighlightClick?: jest.Mock) =>
renderCard(
<HighlightGrid
highlights={highlights}
onHighlightClick={onHighlightClick}
/>,
logEvent,
);

it('copies a highlight from its row without opening it', async () => {
const logEvent = jest.fn();
const onHighlightClick = jest.fn();
renderShareable(logEvent, onHighlightClick);
// The header's page link comes first, then one per row.
const [, firstRow] = screen.getAllByRole('button', { name: 'Copy link' });

await act(async () => {
fireEvent.click(firstRow);
});

expect(onHighlightClick).not.toHaveBeenCalled();
// The post, not a deep link into the page: one highlight shares one link
// wherever it is copied from.
expect(writeText).toHaveBeenCalledWith(
'https://app.daily.dev/posts/post-1',
);
const [[event]] = logEvent.mock.calls;
expect(event).toMatchObject({
event_name: LogEvent.SharePost,
target_id: 'post-1',
target_type: TargetType.Post,
});
expect(JSON.parse(event.extra)).toEqual({
provider: ShareProvider.CopyLink,
origin: Origin.HighlightsCard,
highlight_id: 'highlight-1',
});
});

it('copies the page as an absolute link from the header', async () => {
const logEvent = jest.fn();
renderShareable(logEvent);
const [header] = screen.getAllByRole('button', { name: 'Copy link' });

await act(async () => {
fireEvent.click(header);
});

// `webappUrl` is a bare `/` on the webapp, which pasted as a path.
expect(writeText).toHaveBeenCalledWith('http://localhost/highlights');
const [[event]] = logEvent.mock.calls;
expect(event.event_name).toBe(LogEvent.ShareHighlights);
expect(event.target_id).toBeUndefined();
expect(JSON.parse(event.extra)).toEqual({
provider: ShareProvider.CopyLink,
origin: Origin.HighlightsCard,
});
});

describe('once the short link resolves', () => {
const items: Record<string, Promise<Blob>>[] = [];

beforeEach(() => {
items.length = 0;
Object.assign(globalThis, {
ClipboardItem: class {
constructor(data: Record<string, Promise<Blob>>) {
items.push(data);
}
},
});
Object.assign(navigator.clipboard, {
write: jest.fn().mockResolvedValue(undefined),
});
// An unreachable shortener leaves the tracked long link in place.
jest.spyOn(gqlClient, 'request').mockRejectedValue(new Error('offline'));
});

afterEach(() => {
delete (globalThis as { ClipboardItem?: unknown }).ClipboardItem;
jest.mocked(gqlClient.request).mockRestore();
});

const readSwappedLink = async (): Promise<URL> => {
const blob = await items[0]['text/plain'];
const text = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsText(blob);
});

return new URL(text);
};

it.each([
[
'the page',
0,
'http://localhost/highlights',
ReferralCampaignKey.ShareHighlights,
],
[
'a row',
1,
'https://app.daily.dev/posts/post-1',
ReferralCampaignKey.SharePost,
],
])('tracks %s link to the sharer', async (_, index, expected, cid) => {
renderCard(<HighlightGrid highlights={highlights} />, jest.fn(), {
...loggedUser,
id: 'sharer',
});

await act(async () => {
fireEvent.click(
screen.getAllByRole('button', { name: 'Copy link' })[index],
);
});

const link = await readSwappedLink();
expect(`${link.origin}${link.pathname}`).toBe(expected);
expect(link.searchParams.get('cid')).toBe(cid);
expect(link.searchParams.get('userid')).toBe('sharer');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { useQuery } from '@tanstack/react-query';
import { WidgetContainer } from '../../widgets/common';
import { getHighlightsUrl, highlightsTitleGradientClassName } from './common';
import { highlightsTitleGradientClassName } from './common';
import { getHighlightsUrl } from '../../../lib/links';
import {
majorHeadlinesQueryOptions,
type PostHighlight,
Expand Down
40 changes: 27 additions & 13 deletions packages/shared/src/components/cards/highlight/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { PostHighlight } from '../../../graphql/highlights';
import { webappUrl } from '../../../lib/constants';
import { getHighlightsUrl } from '../../../lib/links';
import { RelativeTime } from '../../utilities/RelativeTime';
import Link from '../../utilities/Link';
import { ButtonSize } from '../../buttons/common';
import { CopyHighlightsLink } from '../../highlights/CopyHighlightsLink';
import { Origin } from '../../../lib/log';
import { HighlightCardOptions } from './HighlightCardOptions';

export interface HighlightCardProps {
Expand All @@ -16,11 +19,6 @@ export interface HighlightCardProps {
export const highlightsTitleGradientClassName =
'feed-highlights-title-gradient';

const HIGHLIGHTS_URL = `${webappUrl}highlights`;

export const getHighlightsUrl = (highlightId?: string): string =>
highlightId ? `${HIGHLIGHTS_URL}?highlight=${highlightId}` : HIGHLIGHTS_URL;

const getHighlightUrl = (highlight: PostHighlight): string =>
getHighlightsUrl(highlight.id);

Expand Down Expand Up @@ -73,18 +71,26 @@ const HighlightRow = ({
return (
<Link href={getHighlightUrl(highlight)}>
<a
className="flex w-full flex-col gap-0 rounded-8 border-b border-border-subtlest-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover"
className="group/highlight flex w-full flex-col gap-0 rounded-8 border-b border-border-subtlest-tertiary px-3 py-2 text-left transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover"
href={getHighlightUrl(highlight)}
onClick={() => onHighlightClick?.(highlight, index + 1)}
>
<span className="break-words font-bold text-text-primary typo-callout">
{highlight.headline}
</span>
<RelativeTime
dateTime={highlight.highlightedAt}
maxHoursAgo={72}
className="mt-0.5 text-text-tertiary typo-footnote"
/>
<span className="mt-0.5 flex items-center gap-1">
<RelativeTime
dateTime={highlight.highlightedAt}
maxHoursAgo={72}
className="text-text-tertiary typo-footnote"
/>
<CopyHighlightsLink
className="pointer-events-none opacity-0 transition-opacity group-focus-within/highlight:opacity-100 group-hover/highlight:pointer-events-auto group-hover/highlight:opacity-100"
highlight={highlight}
origin={Origin.HighlightsCard}
size={ButtonSize.XSmall}
/>
</span>
</a>
</Link>
);
Expand Down Expand Up @@ -118,7 +124,15 @@ export const HighlightCardContent = ({
>
Happening Now
</h3>
<HighlightCardOptions className="ml-auto" />
<CopyHighlightsLink
className={classNames(
'pointer-events-none ml-auto opacity-0 transition-opacity group-hover:pointer-events-auto',
// Keyboard users never fire hover, so focus has to reveal it too.
'focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100',
)}
origin={Origin.HighlightsCard}
/>
<HighlightCardOptions />
</header>
<div className={contentClassName}>
{highlights.map((highlight, index) => (
Expand Down
63 changes: 63 additions & 0 deletions packages/shared/src/components/highlights/CopyHighlightsLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { MouseEvent, ReactElement } from 'react';
import React from 'react';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { LinkIcon } from '../icons/Link';
import { CopyStateIcon } from '../share/CopyStateIcon';
import { Tooltip } from '../tooltip/Tooltip';
import { useCopyLink } from '../../hooks/useCopy';
import type { PostHighlight } from '../../graphql/highlights';
import type { Origin } from '../../lib/log';
import { getAbsoluteWebappUrl } from '../../lib/links';
import { ReferralCampaignKey } from '../../lib/referral';
import { ShareProvider } from '../../lib/share';
import { useLogHighlightShare } from '../../features/snapshot/useLogHighlightShare';

export function CopyHighlightsLink({
highlight,
origin,
className,
size = ButtonSize.Small,
}: {
/** Links to this highlight's post, or to the page without one. */
highlight?: Pick<PostHighlight, 'id' | 'post'>;
origin: Origin;
className?: string;
size?: ButtonSize;
}): ReactElement {
const [copied, copyLink] = useCopyLink();
const logShare = useLogHighlightShare(origin, highlight);

return (
<Tooltip content="Copy link">
<Button
aria-label="Copy link"
className={className}
icon={<CopyStateIcon copied={copied} icon={LinkIcon} />}
onClick={(event: MouseEvent) => {
// The feed card's rows are links.
event.preventDefault();
event.stopPropagation();
logShare(ShareProvider.CopyLink);
// `shorten`, not an awaited short URL: the write has to stay inside
// the task that handled the click or Safari refuses it.
copyLink(
highlight
? {
link: highlight.post.commentsPermalink,
shorten: true,
cid: ReferralCampaignKey.SharePost,
}
: {
link: getAbsoluteWebappUrl('highlights'),
shorten: true,
cid: ReferralCampaignKey.ShareHighlights,
},
);
}}
size={size}
type="button"
variant={ButtonVariant.Tertiary}
/>
</Tooltip>
);
}
Loading
Loading