Skip to content
Open
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
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 { getHighlightsShareUrl } 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: getHighlightsShareUrl(),
shorten: true,
cid: ReferralCampaignKey.ShareHighlights,
},
);
}}
size={size}
type="button"
variant={ButtonVariant.Tertiary}
/>
</Tooltip>
);
}
Loading
Loading