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
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@zumer/snapdom": "^2.23.1",
"border-beam": "1.3.0",
"check-password-strength": "^2.0.10",
"cmdk": "^1.0.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { ReactElement } from 'react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HighlightGrid } from './HighlightGrid';
import { HighlightList } from './HighlightList';

jest.mock('../../../lib/constants', () => ({
webappUrl: '/',
isPreviewHost: () => false,
}));

const highlights = [
Expand All @@ -31,9 +34,15 @@ const highlights = [
},
];

// The copy-link control reaches for the toast, which reads the query client.
const renderCard = (ui: ReactElement) =>
render(
<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>,
);

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 +64,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 +75,7 @@ describe('Highlight cards', () => {
const onHighlightClick = jest.fn();
const onReadAllClick = jest.fn();

render(
renderCard(
<HighlightGrid
highlights={highlights}
onHighlightClick={onHighlightClick}
Expand Down
39 changes: 26 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,11 @@ 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 { HighlightCardOptions } from './HighlightCardOptions';

export interface HighlightCardProps {
Expand All @@ -16,14 +18,11 @@ 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);

export { getHighlightsUrl };

export const ReadAllHighlightsFooter = ({
highlightId,
onClick,
Expand Down Expand Up @@ -73,18 +72,25 @@ 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="opacity-0 transition-opacity group-focus-within/highlight:opacity-100 group-hover/highlight:opacity-100"
link={getHighlightUrl(highlight)}
size={ButtonSize.XSmall}
/>
</span>
</a>
</Link>
);
Expand Down Expand Up @@ -118,7 +124,14 @@ export const HighlightCardContent = ({
>
Happening Now
</h3>
<HighlightCardOptions className="ml-auto" />
<CopyHighlightsLink
className={classNames(
'ml-auto opacity-0 transition-opacity',
// 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',
)}
/>
<HighlightCardOptions />
</header>
<div className={contentClassName}>
{highlights.map((highlight, index) => (
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/components/highlights/CopyHighlightsLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { MouseEvent, ReactElement } from 'react';
import React from 'react';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { LinkIcon } from '../icons';
import { CopyStateIcon } from '../share/CopyStateIcon';
import { Tooltip } from '../tooltip/Tooltip';
import { useCopyText } from '../../hooks/useCopy';
import { getHighlightsUrl } from '../../lib/links';
import { useSharePlacement } from '../../features/snapshot/useSharePlacement';
import { featureHappeningNowShare } from '../../lib/featureManagement';

export function CopyHighlightsLink({
link,
className,
size = ButtonSize.Small,
}: {
link?: string;
className?: string;
size?: ButtonSize;
}): ReactElement | null {
const isEnabled = useSharePlacement({ feature: featureHappeningNowShare });
// useCopyText, not useCopyLink: the link variant reaches for the shortener,
// which needs an authenticated user, and the page has to work signed out.
const [copied, copyLink] = useCopyText(link ?? getHighlightsUrl());

if (!isEnabled) {
return null;
}

return (
<Tooltip content="Copy link">
<Button
aria-label="Copy link"
className={className}
icon={<CopyStateIcon copied={copied} icon={LinkIcon} />}
onClick={(event: MouseEvent) => {
// The feed card is a link, and the page header sits above a tab bar.
event.preventDefault();
event.stopPropagation();
copyLink({ message: '✅ Copied link' });
}}
size={size}
type="button"
variant={ButtonVariant.Tertiary}
/>
</Tooltip>
);
}
41 changes: 41 additions & 0 deletions packages/shared/src/components/highlights/HighlightItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React from 'react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { render, screen } from '@testing-library/react';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import type { PostHighlightFeed } from '../../graphql/highlights';
import { featureHappeningNowShare } from '../../lib/featureManagement';
import { HighlightItem } from './HighlightItem';

const scrollIntoView = jest.fn();
Expand Down Expand Up @@ -30,6 +34,19 @@ beforeEach(() => {
scrollIntoView.mockClear();
});

const renderWithSnapshot = (defaultExpanded = false) => {
const gb = new GrowthBook();
gb.setFeatures({
[featureHappeningNowShare.id]: { defaultValue: true },
});

return render(
<TestBootProvider client={new QueryClient()} gb={gb}>
<HighlightItem defaultExpanded={defaultExpanded} highlight={highlight} />
</TestBootProvider>,
);
};

describe('HighlightItem', () => {
it('should expand when the route-driven default changes after mount', () => {
const { rerender } = render(<HighlightItem highlight={highlight} />);
Expand All @@ -45,4 +62,28 @@ describe('HighlightItem', () => {
);
expect(scrollIntoView).toHaveBeenCalled();
});

it('keeps an expanded highlight free of share controls while the flag is off', () => {
render(<HighlightItem defaultExpanded highlight={highlight} />);

expect(
screen.queryByRole('button', { name: /snapshot/i }),
).not.toBeInTheDocument();
});

it('offers nothing on a collapsed row even with the flag on', () => {
renderWithSnapshot();

expect(
screen.queryByRole('button', { name: /snapshot/i }),
).not.toBeInTheDocument();
});

it('offers snapshot and copy link beside Read more when expanded', () => {
renderWithSnapshot(true);

expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible();
expect(screen.getByRole('button', { name: /copy link/i })).toBeVisible();
expect(screen.getByRole('link', { name: /read more/i })).toBeVisible();
});
});
46 changes: 39 additions & 7 deletions packages/shared/src/components/highlights/HighlightItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { ArrowIcon } from '../icons/Arrow';
import { IconSize } from '../Icon';
import Link from '../utilities/Link';
import { RelativeTime } from '../utilities/RelativeTime';
import { HighlightSelectionBar } from '../../features/snapshot/HighlightSelectionBar';
import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions';
import { useSharePlacement } from '../../features/snapshot/useSharePlacement';
import { featureHappeningNowShare } from '../../lib/featureManagement';

const MAX_HOURS_AGO = 72;

interface HighlightItemProps {
highlight: PostHighlightFeed;
Expand All @@ -20,6 +26,11 @@ export const HighlightItem = ({
}: HighlightItemProps): ReactElement => {
const [expanded, setExpanded] = useState(defaultExpanded);
const ref = useRef<HTMLElement>(null);
const tldrRef = useRef<HTMLParagraphElement>(null);
const canSnapshot = useSharePlacement({
feature: featureHappeningNowShare,
shouldEvaluate: expanded,
});

useEffect(() => {
if (defaultExpanded) {
Expand Down Expand Up @@ -66,7 +77,7 @@ export const HighlightItem = ({
</span>
<RelativeTime
dateTime={highlight.highlightedAt}
maxHoursAgo={72}
maxHoursAgo={MAX_HOURS_AGO}
className="mt-0.5 text-text-quaternary typo-footnote"
/>
</div>
Expand All @@ -80,12 +91,33 @@ export const HighlightItem = ({
</button>
{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>
<p
ref={tldrRef}
className="select-text text-text-secondary typo-markdown"
>
{tldr}
</p>
{canSnapshot && (
<HighlightSelectionBar
containerRef={tldrRef}
id={highlight.id}
link={highlight.post.commentsPermalink}
/>
)}
<div className="flex items-center gap-3">
<Link href={highlight.post.commentsPermalink}>
<a className="flex flex-1 items-center gap-1 font-bold text-text-link typo-footnote hover:underline">
Read more
</a>
</Link>
{canSnapshot && (
<HighlightShareActions
id={highlight.id}
link={highlight.post.commentsPermalink}
tldr={tldr}
/>
)}
</div>
</div>
)}
</article>
Expand Down
6 changes: 4 additions & 2 deletions packages/shared/src/components/highlights/HighlightsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
postHighlightsFeedQueryOptions,
} from '../../graphql/highlights';
import { Tab, TabContainer } from '../tabs/TabContainer';
import { CopyHighlightsLink } from './CopyHighlightsLink';
import { DigestCTA } from './DigestCTA';
import { HighlightItem } from './HighlightItem';

Expand Down Expand Up @@ -173,10 +174,11 @@ export const HighlightsPage = (): ReactElement => {

return (
<main className="mx-auto flex w-full max-w-2xl flex-col pb-8 laptop:min-h-page laptop:border-x laptop:border-border-subtlest-tertiary">
<header className="flex items-center px-3 py-4 laptop:px-4">
<h1 className="feed-highlights-title-gradient font-bold typo-large-title">
<header className="flex items-center gap-3 px-3 py-4 laptop:px-4">
<h1 className="feed-highlights-title-gradient flex-1 font-bold typo-large-title">
Happening Now
</h1>
<CopyHighlightsLink />
</header>
<TabContainer
controlledActive={activeTab}
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/components/icons/Snapshot/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/shared/src/components/icons/Snapshot/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { IconProps } from '../../Icon';
import Icon from '../../Icon';
import OutlinedIcon from './outlined.svg';
import FilledIcon from './filled.svg';

export const SnapshotIcon = (props: IconProps): ReactElement => (
<Icon {...props} IconPrimary={OutlinedIcon} IconSecondary={FilledIcon} />
);
11 changes: 11 additions & 0 deletions packages/shared/src/components/icons/Snapshot/outlined.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/shared/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export * from './Shortcuts';
export * from './Sidebar';
export * from './Sites';
export * from './Slack';
export * from './Snapshot';
export * from './Sort';
export * from './Source';
export * from './Sparkle';
Expand Down
Loading
Loading