Skip to content
Open
7 changes: 6 additions & 1 deletion packages/shared/src/components/buttons/CardAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,23 @@ import { ButtonSize, ButtonVariant, ButtonIconPosition } from './common';
import type { ColorName } from '../../styles/colors';
import InteractionCounter from '../InteractionCounter';

export type CardActionDensity = 'comfortable' | 'compact';
export type CardActionDensity = 'comfortable' | 'compact' | 'tight';

const densityToSize: Record<CardActionDensity, ButtonSize> = {
comfortable: ButtonSize.Medium,
compact: ButtonSize.Small,
tight: ButtonSize.XSmall,
};

// Larger than buttonSizeToIconSizeV2: engagement-bar icons sit closer
// to a 60% ratio (Material 3, Instagram, Reddit) so they read at a glance.
// `tight` is the feed-card tier: buttons never shrink (global flex-shrink: 0),
// so six actions plus counters have to fit the 272px min card width on their
// intrinsic widths alone. It matches the floating glass bar.
const densityToIconSize: Record<CardActionDensity, IconSize> = {
comfortable: IconSize.Small,
compact: IconSize.XSmall,
tight: IconSize.Size16,
};

type IconElement = React.ReactElement<IconProps>;
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/components/buttons/CardActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export type CardActionBarLayout =

const layoutToClass: Record<CardActionBarLayout, string> = {
default: 'gap-1',
feedCard: 'flex-1 min-w-0 gap-1 justify-between',
// No `gap` on the feed row: `justify-between` already spreads the actions
// whenever there is slack, and because buttons never shrink (global
// flex-shrink: 0) a gap only adds width the 272px min card cannot give back.
feedCard: 'flex-1 min-w-0 justify-between',
between: 'gap-1 justify-between w-full',
compact: 'gap-0.5',
};
Expand Down
83 changes: 83 additions & 0 deletions packages/shared/src/components/cards/common/ActionButtons.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import ActionButtons from './ActionButtons';
import type { ActionButtonsVariant } from './ActionButtons';
import post from '../../../../__tests__/fixture/post';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import { usePostImpressions } from '../../../hooks/post/usePostImpressions';
import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2';
import { useViewSize } from '../../../hooks/useViewSize';

jest.mock('../../../hooks/post/usePostImpressions', () => ({
usePostImpressions: jest.fn(),
}));

// Awards used to survive on laptop when impressions were enabled, and jsdom
// reports every media query as unmatched, so the viewport has to be forced.
jest.mock('../../../hooks/useViewSize', () => ({
...jest.requireActual('../../../hooks/useViewSize'),
useViewSize: jest.fn(),
}));

jest.mock('../../../hooks/post/usePostImpressionsModal', () => ({
usePostImpressionsModal: () => jest.fn(),
}));

jest.mock('../../../hooks/useEngagementBarV2', () => ({
useEngagementBarV2: jest.fn(),
}));

jest.mock('../../post/PostAwardAction', () => ({
__esModule: true,
default: () => <div data-testid="award-action" />,
}));

const mockImpressions = (enabled: boolean) =>
jest.mocked(usePostImpressions).mockReturnValue({
enabled,
showImpressions: enabled,
impressions: enabled ? 1000 : 0,
});

const renderComponent = (variant: ActionButtonsVariant) =>
render(
<TestBootProvider client={new QueryClient()}>
<ActionButtons post={post} variant={variant} />
</TestBootProvider>,
);

const variants: ActionButtonsVariant[] = ['grid', 'list', 'signal'];

describe.each([false, true])('ActionButtons (v2: %s)', (isV2) => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useEngagementBarV2).mockReturnValue(isV2);
jest.mocked(useViewSize).mockReturnValue(true);
});

it.each(variants)(
'hides the award action on a %s card when impressions are enabled',
(variant) => {
mockImpressions(true);

renderComponent(variant);

expect(screen.queryByTestId('award-action')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Impressions' }),
).toBeInTheDocument();
},
);

it.each(variants)(
'keeps the award action on a %s card when impressions are disabled',
(variant) => {
mockImpressions(false);

renderComponent(variant);

expect(screen.getByTestId('award-action')).toBeInTheDocument();
},
);
});
51 changes: 28 additions & 23 deletions packages/shared/src/components/cards/common/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
DownvoteIcon,
} from '../../icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { useFeedPreviewMode } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons';
import { IconSize } from '../../Icon';
Expand Down Expand Up @@ -43,30 +43,43 @@ export interface ActionButtonsProps {
showAwardAction?: boolean;
}

// Sizing follows the floating glass bar (FeedCardGlassActions): XSmall buttons
// with 16px icons, so six actions incl. the impressions counter fit a 272px
// card without the trailing number reaching the edge.
const variantConfig = {
grid: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
containerClassName: 'px-1 pb-1',
buttonSize: ButtonSize.XSmall,
iconSize: IconSize.Size16,
// Asymmetric like the glass pill: the left edge holds an icon, the right
// edge holds the impressions number, which needs more room to sit right.
// `py-1.5` around the h-6 buttons gives the row the same 36px height the
// bar had at the previous (wider) button size, so shrinking the actions to
// fit the 272px card doesn't make the bar read as cramped vertically.
containerClassName: 'py-1.5 pl-1 pr-2.5',
showTagsPanel: false,
useCommentLink: false,
},
list: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
buttonSize: ButtonSize.XSmall,
iconSize: IconSize.Size16,
containerClassName: '',
showTagsPanel: true,
useCommentLink: true,
},
signal: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
buttonSize: ButtonSize.XSmall,
iconSize: IconSize.Size16,
containerClassName: '',
showTagsPanel: false,
useCommentLink: true,
},
} as const;

// Monospaced digits so counters never jitter, and a hair of padding on each
// side of the number — both taken from the glass bar.
const counterClassName = 'tabular-nums typo-footnote';
const counterLabelClassName = '!pl-0.5 pr-0.5';

const ActionButtonsV1 = ({
post,
onUpvoteClick,
Expand All @@ -81,14 +94,7 @@ const ActionButtonsV1 = ({
}: ActionButtonsProps): ReactElement | null => {
const config = variantConfig[variant];
const isFeedPreview = useFeedPreviewMode();
const isLaptop = useViewSize(ViewSize.Laptop);
const { buttonSize, iconSize } = config;
// On mobile/tablet keep full-size icons but shrink the count so the icon
// reads as the primary affordance and the number as a subtle stat.
const counterClassName = classNames(
'tabular-nums',
isLaptop ? variant === 'grid' && 'typo-footnote' : 'typo-caption1',
);
const { getUpvoteAnimation } = useBrandSponsorship();

const {
Expand Down Expand Up @@ -145,7 +151,7 @@ const ActionButtonsV1 = ({
href={post.commentsPermalink}
>
<QuaternaryButton
labelClassName="!pl-0"
labelClassName={counterLabelClassName}
id={`post-${post.id}-comment-btn`}
className="btn-tertiary-blueCheese pointer-events-auto"
color={ButtonColor.BlueCheese}
Expand All @@ -171,7 +177,7 @@ const ActionButtonsV1 = ({
) : (
<Tooltip content="Comments" side="bottom">
<QuaternaryButton
labelClassName="!pl-[1px]"
labelClassName={counterLabelClassName}
id={`post-${post.id}-comment-btn`}
icon={<CommentIcon secondary={post.commented} size={iconSize} />}
pressed={post.commented}
Expand Down Expand Up @@ -206,7 +212,7 @@ const ActionButtonsV1 = ({
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
labelClassName={variant === 'grid' ? '!pl-[1px]' : '!pl-0'}
labelClassName={counterLabelClassName}
className="btn-tertiary-avocado pointer-events-auto"
id={`post-${post.id}-upvote-btn`}
color={ButtonColor.Avocado}
Expand Down Expand Up @@ -250,10 +256,9 @@ const ActionButtonsV1 = ({
/>
</Tooltip>
)}
{/* When impressions are enabled, drop awards below laptop to make room
for the extra action; with the flag off, awards stay on every
viewport (unchanged from control). */}
{showAwardAction && (!impressionsEnabled || isLaptop) && (
{/* Impressions take the award slot on feed cards; with the flag off,
awards stay (unchanged from control). */}
{showAwardAction && !impressionsEnabled && (
<PostAwardAction post={post} iconSize={iconSize} />
)}
<BookmarkButton
Expand Down Expand Up @@ -293,7 +298,7 @@ const ActionButtonsV1 = ({
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
labelClassName={variant === 'grid' ? '!pl-[1px]' : '!pl-0'}
labelClassName={counterLabelClassName}
id={`post-${post.id}-impressions-btn`}
size={buttonSize}
icon={<AnalyticsIcon size={iconSize} />}
Expand Down
14 changes: 7 additions & 7 deletions packages/shared/src/components/cards/common/ActionButtons.v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
DownvoteIcon,
} from '../../icons';
import { ButtonColor } from '../../buttons/ButtonV2';
import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { useFeedPreviewMode } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons/BookmarkButton.v2';
import { Tooltip } from '../../tooltip/Tooltip';
Expand Down Expand Up @@ -39,11 +39,14 @@ export interface ActionButtonsProps {
showAwardAction?: boolean;
}

const FEED_CARD_DENSITY = 'compact';
const FEED_CARD_DENSITY = 'tight';

const variantConfig = {
grid: {
containerClassName: 'px-1 pb-1',
// Same padding as the v1 bar and the glass pill: `py-1.5` keeps the row at
// 36px around the h-6 buttons, and the wider right edge gives the trailing
// impressions number room so it doesn't read as touching the card.
containerClassName: 'py-1.5 pl-1 pr-2.5',
showTagsPanel: false,
useCommentLink: false,
},
Expand Down Expand Up @@ -73,9 +76,6 @@ const ActionButtons = ({
}: ActionButtonsProps): ReactElement | null => {
const config = variantConfig[variant];
const isFeedPreview = useFeedPreviewMode();
// When impressions are enabled, awards are hidden below laptop (tablet +
// mobile) to make room for the extra action.
const isLaptop = useViewSize(ViewSize.Laptop);
const { getUpvoteAnimation } = useBrandSponsorship();

const {
Expand Down Expand Up @@ -207,7 +207,7 @@ const ActionButtons = ({
/>
</Tooltip>
)}
{showAwardAction && (!impressionsEnabled || isLaptop) && (
{showAwardAction && !impressionsEnabled && (
<PostAwardAction post={post} density={FEED_CARD_DENSITY} />
)}
<BookmarkButton
Expand Down
83 changes: 83 additions & 0 deletions packages/shared/src/components/post/PostAwardAction.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import PostAwardAction from './PostAwardAction';
import { useAuthContext } from '../../contexts/AuthContext';
import { useCanAwardUser } from '../../hooks/useCoresFeature';
import { useEngagementBarV2 } from '../../hooks/useEngagementBarV2';
import type { Post } from '../../graphql/posts';
import type { LoggedUser } from '../../lib/user';

jest.mock('../../contexts/AuthContext', () => ({
useAuthContext: jest.fn(),
}));

jest.mock('../../hooks/useCoresFeature', () => ({
useCanAwardUser: jest.fn(),
}));

jest.mock('../../hooks/useEngagementBarV2', () => ({
useEngagementBarV2: jest.fn(),
}));

jest.mock('../../hooks/useLazyModal', () => ({
useLazyModal: () => ({ openModal: jest.fn() }),
}));

const post = {
id: 'p1',
numAwards: 0,
} as Post;

const mockAuth = (user: Partial<LoggedUser> | null) =>
jest
.mocked(useAuthContext)
.mockReturnValue({ user, showLogin: jest.fn() } as never);

const renderComponent = (postProps: Partial<Post> = {}) =>
render(
<QueryClientProvider client={new QueryClient()}>
<PostAwardAction post={{ ...post, ...postProps } as Post} />
</QueryClientProvider>,
);

describe.each([false, true])('PostAwardAction (v2: %s)', (isV2) => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useEngagementBarV2).mockReturnValue(isV2);
jest.mocked(useCanAwardUser).mockReturnValue(false);
});

it('stays hidden for a logged out user on a post without an author', () => {
mockAuth(null);

renderComponent();

expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('stays hidden for a logged in user on a post without an author', () => {
mockAuth({ id: 'u1' });

renderComponent();

expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('renders on the logged in user own post', () => {
mockAuth({ id: 'u1' });

renderComponent({ author: { id: 'u1' } as Post['author'] });

expect(screen.getByRole('button')).toBeInTheDocument();
});

it('renders when the author can be awarded', () => {
mockAuth({ id: 'u1' });
jest.mocked(useCanAwardUser).mockReturnValue(true);

renderComponent({ author: { id: 'u2' } as Post['author'] });

expect(screen.getByRole('button')).toBeInTheDocument();
});
});
5 changes: 3 additions & 2 deletions packages/shared/src/components/post/PostAwardAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ import { AuthTriggers } from '../../lib/auth';
import { LazyModal } from '../modals/common/types';
import type { LoggedUser } from '../../lib/user';
import { useEngagementBarV2 } from '../../hooks/useEngagementBarV2';
import type { CardActionDensity } from '../buttons/CardAction';
import PostAwardActionV2 from './PostAwardAction.v2';

export interface PostAwardActionProps {
post: Post;
iconSize?: IconSize;
density?: 'comfortable' | 'compact';
density?: CardActionDensity;
}

const PostAwardActionV1 = ({ post, iconSize }: PostAwardActionProps) => {
const { openModal } = useLazyModal();
const { user, showLogin } = useAuthContext();
const isSameUser = user?.id === post?.author?.id;
const isSameUser = !!user?.id && user.id === post?.author?.id;
const canAward = useCanAwardUser({
sendingUser: user,
receivingUser: post?.author as LoggedUser,
Expand Down
Loading
Loading