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
31 changes: 29 additions & 2 deletions packages/shared/src/components/highlights/DigestCTA.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import type { ChannelDigestConfiguration } from '../../graphql/highlights';
import type { Source } from '../../graphql/sources';
import { SourceType } from '../../graphql/sources';
Expand All @@ -9,6 +10,9 @@ import { useSourceActionsFollow } from '../../hooks/source/useSourceActionsFollo
import { useSourceActionsNotify } from '../../hooks/source/useSourceActionsNotify';
import SourceActionsNotify from '../sources/SourceActions/SourceActionsNotify';
import Link from '../utilities/Link';
import { HighlightShareButton } from './HighlightShareButton';
import { ButtonSize } from '../buttons/Button';
import { ReferralCampaignKey } from '../../lib/referral';

const CTA_HEIGHT = 'h-10';

Expand All @@ -22,6 +26,12 @@ const DigestCTASkeleton = (): ReactElement => (
interface DigestCTAProps {
digest: ChannelDigestConfiguration;
displayName: string;
/**
* Absolute link to this channel's Happening Now tab. Only set when
* `share_happening_now` is on, so the control is gated on the prop and the
* flag-off DOM is untouched.
*/
shareLink?: string;
}

interface DigestCTAContentProps extends DigestCTAProps {
Expand All @@ -31,6 +41,7 @@ interface DigestCTAContentProps extends DigestCTAProps {
const DigestCTAContent = ({
digest,
displayName,
shareLink,
source,
}: DigestCTAContentProps): ReactElement => {
const { isAuthReady, isLoggedIn } = useAuthContext();
Expand Down Expand Up @@ -61,7 +72,9 @@ const DigestCTAContent = ({
<div
className={`flex items-center gap-2 px-4 py-2 text-text-tertiary typo-callout ${CTA_HEIGHT}`}
>
<span>
{/* The share control eats horizontal room, so the copy only gets the
shrink + ellipsis treatment when it is actually rendered. */}
<span className={classNames(shareLink && 'min-w-0 flex-1 truncate')}>
Get a {digest.frequency} digest of{' '}
<Link href={source.permalink}>
<a className="font-bold text-text-primary hover:underline">
Expand All @@ -70,7 +83,19 @@ const DigestCTAContent = ({
</Link>{' '}
news
</span>
<span className="ml-auto shrink-0">
{shareLink && (
<HighlightShareButton
link={shareLink}
text={`Follow ${displayName} news on daily.dev`}
label={`Share ${displayName}`}
level="topic"
targetId={source.id ?? displayName}
cid={ReferralCampaignKey.Generic}
buttonSize={ButtonSize.XSmall}
className="ml-auto shrink-0"
/>
)}
<span className={classNames('shrink-0', !shareLink && 'ml-auto')}>
<SourceActionsNotify
haveNotificationsOn={haveNotificationsOn}
onClick={(event) => {
Expand All @@ -87,6 +112,7 @@ const DigestCTAContent = ({
export const DigestCTA = ({
digest,
displayName,
shareLink,
}: DigestCTAProps): ReactElement | null => {
const source = digest.source
? {
Expand All @@ -104,6 +130,7 @@ export const DigestCTA = ({
<DigestCTAContent
digest={digest}
displayName={displayName}
shareLink={shareLink}
source={source}
/>
);
Expand Down
99 changes: 97 additions & 2 deletions packages/shared/src/components/highlights/HighlightItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import type { PostHighlightFeed } from '../../graphql/highlights';
import { HighlightItem } from './HighlightItem';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { useViewSize } from '../../hooks/useViewSize';
import type { ToastNotification } from '../../hooks/useToastNotification';
import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification';

jest.mock('../../hooks/useViewSize', () => {
const actual = jest.requireActual('../../hooks/useViewSize');
return { __esModule: true, ...actual, useViewSize: jest.fn() };
});

const useViewSizeMock = useViewSize as jest.Mock;
const scrollIntoView = jest.fn();
const summary = 'A concise summary for the expanded highlight item.';
const SHARE_LABEL = 'Share this highlight';

const highlight: PostHighlightFeed = {
id: 'highlight-1',
Expand All @@ -27,7 +45,8 @@ beforeAll(() => {
});

beforeEach(() => {
scrollIntoView.mockClear();
jest.clearAllMocks();
useViewSizeMock.mockReturnValue(true); // default: laptop
});

describe('HighlightItem', () => {
Expand All @@ -46,3 +65,79 @@ describe('HighlightItem', () => {
expect(scrollIntoView).toHaveBeenCalled();
});
});

let client: QueryClient;

const renderWithShare = (showShare: boolean) => {
client = new QueryClient();
return render(
<TestBootProvider client={client}>
<HighlightItem
highlight={highlight}
defaultExpanded
showShare={showShare}
/>
</TestBootProvider>,
);
};

describe('HighlightItem share control', () => {
it('should not render a share control when the flag is off', () => {
renderWithShare(false);

expect(
screen.getByRole('link', { name: /read more/i }),
).toBeInTheDocument();
expect(screen.queryByLabelText(SHARE_LABEL)).not.toBeInTheDocument();
});

it('should render exactly one share control per highlight when enabled', () => {
renderWithShare(true);

expect(screen.getAllByLabelText(SHARE_LABEL)).toHaveLength(1);
expect(
screen.getByRole('link', { name: /read more/i }),
).toBeInTheDocument();
});

it('should copy the post link and show a toast on desktop', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

renderWithShare(true);

await act(async () => {
fireEvent.click(screen.getByLabelText(SHARE_LABEL));
});
await act(async () => {
fireEvent.click(await screen.findByText('Copy link'));
});

await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('/posts/post-1'),
);
await waitFor(() =>
expect(
client.getQueryData<ToastNotification>(TOAST_NOTIF_KEY)?.message,
).toEqual('✅ Copied link to clipboard'),
);
});

it('should open the native share sheet on a single tap on mobile', async () => {
useViewSizeMock.mockReturnValue(false);
const share = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { share, maxTouchPoints: 2 });

renderWithShare(true);

await act(async () => {
fireEvent.click(screen.getByLabelText(SHARE_LABEL));
});

await waitFor(() =>
expect(share).toHaveBeenCalledWith({
text: `${highlight.headline}\n/posts/post-1`,
}),
);
});
});
35 changes: 30 additions & 5 deletions packages/shared/src/components/highlights/HighlightItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,23 @@ import { stripHtmlTags } from '../../lib/strings';
import { PostType } from '../../graphql/posts';
import { ArrowIcon } from '../icons/Arrow';
import { IconSize } from '../Icon';
import { ButtonSize } from '../buttons/Button';
import Link from '../utilities/Link';
import { RelativeTime } from '../utilities/RelativeTime';
import { HighlightShareButton } from './HighlightShareButton';
import { ReferralCampaignKey } from '../../lib/referral';

interface HighlightItemProps {
highlight: PostHighlightFeed;
defaultExpanded?: boolean;
/** Gated by `share_happening_now`; resolved once by `HighlightsPage`. */
showShare?: boolean;
}

export const HighlightItem = ({
highlight,
defaultExpanded = false,
showShare = false,
}: HighlightItemProps): ReactElement => {
const [expanded, setExpanded] = useState(defaultExpanded);
const ref = useRef<HTMLElement>(null);
Expand Down Expand Up @@ -52,6 +58,14 @@ export const HighlightItem = ({
return '';
}, [highlight.post]);

const readMore = (
<Link href={highlight.post.commentsPermalink}>
<a className="flex items-center gap-1 font-bold text-text-link typo-footnote hover:underline">
Read more
</a>
</Link>
);

return (
<article ref={ref}>
<button
Expand Down Expand Up @@ -81,11 +95,22 @@ export const HighlightItem = ({
{expanded && tldr && (
<div className="flex flex-col gap-3 px-4 pb-3">
<p className="text-text-secondary typo-markdown">{tldr}</p>
<Link href={highlight.post.commentsPermalink}>
<a className="flex items-center gap-1 font-bold text-text-link typo-footnote hover:underline">
Read more
</a>
</Link>
{showShare ? (
<div className="flex items-center gap-2">
{readMore}
<HighlightShareButton
link={highlight.post.commentsPermalink}
text={highlight.headline}
label="Share this highlight"
level="highlight"
targetId={highlight.id}
cid={ReferralCampaignKey.SharePost}
buttonSize={ButtonSize.XSmall}
/>
</div>
) : (
readMore
)}
</div>
)}
</article>
Expand Down
68 changes: 68 additions & 0 deletions packages/shared/src/components/highlights/HighlightShareButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { ReactElement } from 'react';
import React, { useCallback } from 'react';
import { ShareActions } from '../share/ShareActions';
import type { ButtonSize } from '../buttons/Button';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent, Origin } from '../../lib/log';
import type { ReferralCampaignKey } from '../../lib/referral';
import type { ShareProvider } from '../../lib/share';

// Which of the three Happening Now nesting levels the control belongs to. Kept
// on the log payload so the levels can be compared without three log events.
export type HighlightShareLevel = 'page' | 'topic' | 'highlight';

export interface HighlightShareButtonProps {
link: string;
text: string;
label: string;
level: HighlightShareLevel;
targetId: string;
cid?: ReferralCampaignKey;
buttonSize?: ButtonSize;
className?: string;
}

// Thin logging wrapper around the shared `ShareActions` primitive so all three
// Happening Now levels emit an identically shaped event. `LogEvent.ShareLog` is
// the generic share event: a highlight is not a full `Post` here (the feed query
// only returns id + permalink), so emitting `SharePost` would produce events
// missing every standard post property.
export const HighlightShareButton = ({
link,
text,
label,
level,
targetId,
cid,
buttonSize,
className,
}: HighlightShareButtonProps): ReactElement => {
const { logEvent } = useLogContext();

const onShare = useCallback(
(provider: ShareProvider) => {
logEvent({
event_name: LogEvent.ShareLog,
target_id: targetId,
extra: JSON.stringify({
origin: Origin.HappeningNow,
provider,
level,
}),
});
},
[logEvent, targetId, level],
);

return (
<ShareActions
link={link}
text={text}
label={label}
cid={cid}
buttonSize={buttonSize}
className={className}
onShare={onShare}
/>
);
};
Loading
Loading