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
27 changes: 19 additions & 8 deletions packages/shared/src/components/InteractionCounter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ import { largeNumberFormat } from '../lib';
export type InteractionCounterProps = {
className?: string;
value: number | null;
/** Override the number formatter (defaults to `largeNumberFormat`). */
format?: (value: number | null) => string | null;
};

export default function InteractionCounter({
className,
value,
format = largeNumberFormat,
...props
}: InteractionCounterProps): ReactElement {
const [shownValue, setShownValue] = useState(value);
const [animate, setAnimate] = useState(false);
useEffect(() => {
const formattedValue = largeNumberFormat(value);
const formattedShownValue = largeNumberFormat(shownValue);
const formattedValue = format(value);
const formattedShownValue = format(shownValue);
if (formattedValue !== formattedShownValue) {
if (value < shownValue) {
if ((value ?? 0) < (shownValue ?? 0)) {
setShownValue(value);
} else {
setAnimate(false);
Expand All @@ -37,9 +40,15 @@ export default function InteractionCounter({
);

if (shownValue === value) {
// Center the number within the fixed-height (h-5) box. Without this the text
// is top-aligned, so smaller type (e.g. typo-caption1 on mobile/tablet, whose
// 1rem line-height is shorter than h-5) sits visibly higher than the icon.
return (
<span className={elementClassName} {...props}>
{largeNumberFormat(shownValue)}
<span
className={classNames(elementClassName, 'justify-center')}
{...props}
>
{format(shownValue)}
</span>
);
}
Expand All @@ -49,8 +58,10 @@ export default function InteractionCounter({
setShownValue(value);
};

// leading-5 makes each rolling slice's line box fill its h-5 height so the
// digits stay centered during the roll (matches the resting state above).
const childClassName =
'h-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]';
'h-5 leading-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]';

return (
<span className={elementClassName} {...props}>
Expand All @@ -60,7 +71,7 @@ export default function InteractionCounter({
animate ? '-translate-y-full opacity-0' : 'translate-y-0 opacity-100',
)}
>
{largeNumberFormat(shownValue)}
{format(shownValue)}
</span>
<span
className={classNames(
Expand All @@ -69,7 +80,7 @@ export default function InteractionCounter({
)}
onTransitionEnd={updateShownValue}
>
{largeNumberFormat(value)}
{format(value)}
</span>
</span>
);
Expand Down
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 @@ -46,6 +46,8 @@ type CardActionBaseProps = CardActionPassthroughProps & {
iconPressed?: IconElement;
label: string;
count?: number | null;
/** Override the counter formatter (defaults to `largeNumberFormat`). */
countFormat?: (value: number | null) => string | null;
color?: ColorName;
pressed?: boolean;
loading?: boolean;
Expand All @@ -64,6 +66,7 @@ function CardActionComponent(
iconPressed,
label,
count,
countFormat,
color,
pressed,
loading,
Expand Down Expand Up @@ -121,7 +124,9 @@ function CardActionComponent(
{showLabel && (
<span className="card-action-label truncate">{label}</span>
)}
{showCount && <InteractionCounter value={count ?? 0} />}
{showCount && (
<InteractionCounter value={count ?? 0} format={countFormat} />
)}
</span>
)}
</ButtonV2>
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/components/buttons/QuaternaryButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ function QuaternaryButtonComponent<TagName extends AllowedTags>(
htmlFor={id}
{...labelProps}
className={classNames(
'flex cursor-pointer items-center pl-1 font-bold typo-callout',
// Medium weight to match the button typography guideline (the
// Button base and CardAction both use font-medium).
'flex cursor-pointer items-center pl-1 font-medium typo-callout',
{ readOnly: props.disabled },
labelClassName,
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { FeedCardGlassActions } from '../common/FeedCardGlassActions';
import { FeedbackGrid } from './feedback/FeedbackGrid';
import { ClickbaitShield } from '../common/ClickbaitShield';
import { useSmartTitle } from '../../../hooks/post/useSmartTitle';
import { useFitFontSize } from '../../../hooks/useFitFontSize';
import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions';
import { usePostImage } from '../../../hooks/post/usePostImage';
import { useCardCover } from '../../../hooks/feed/useCardCover';
Expand All @@ -51,6 +52,12 @@ const IMAGE_COL_SPAN: Record<FeaturedWideColSpan, string> = {
4: 'col-span-3',
};

// The hero title shrinks (title1 → title3) to stay within three lines so the
// summary below always has room, rather than spilling onto a fourth line and
// pushing the TLDR off the card.
const HERO_TITLE_SIZE_CLASSES = ['typo-title1', 'typo-title2', 'typo-title3'];
const HERO_TITLE_MAX_LINES = 3;

const HighlightChip = ({
significance,
className,
Expand Down Expand Up @@ -148,6 +155,15 @@ export const ArticleFeaturedWideGridCard = forwardRef(
const { pinnedAt } = post;
const { showFeedback } = usePostFeedback({ post });
const { title } = useSmartTitle(post);
const {
ref: titleRef,
sizeClass: titleSizeClass,
isClamped: titleClamped,
} = useFitFontSize<HTMLHeadingElement>({
text: title,
sizeClasses: HERO_TITLE_SIZE_CLASSES,
maxLines: HERO_TITLE_MAX_LINES,
});
const isVideoType = isVideoPost(post);
const image = usePostImage(post);
const { overlay } = useCardCover({ post, onShare });
Expand Down Expand Up @@ -276,9 +292,11 @@ export const ArticleFeaturedWideGridCard = forwardRef(
showFeedback={false}
/>
<h3
ref={titleRef}
className={classNames(
'mt-2 break-words font-bold text-text-primary typo-title1',
useGlass ? 'line-clamp-3' : 'line-clamp-4',
'mt-2 break-words font-bold text-text-primary',
titleSizeClass,
titleClamped && 'line-clamp-3',
)}
>
{title}
Expand Down
95 changes: 70 additions & 25 deletions packages/shared/src/components/cards/common/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import type { Post } from '../../../graphql/posts';
import InteractionCounter from '../../InteractionCounter';
import { QuaternaryButton } from '../../buttons/QuaternaryButton';
import {
AnalyticsIcon,
DiscussIcon as CommentIcon,
LinkIcon,
DownvoteIcon,
} from '../../icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { useFeedPreviewMode } from '../../../hooks';
import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons';
import { IconSize } from '../../Icon';
Expand All @@ -21,6 +22,9 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel';
import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip';
import { useCardActions } from '../../../hooks/cards/useCardActions';
import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship';
import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
import { usePostImpressions } from '../../../hooks/post/usePostImpressions';
import { formatImpressions } from '../../../lib/impressions';
import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2';
import ActionButtonsV2 from './ActionButtons.v2';

Expand Down Expand Up @@ -78,6 +82,14 @@ 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 @@ -114,6 +126,13 @@ const ActionButtonsV1 = ({
};
}, [getUpvoteAnimation, post.tags]);

const onImpressionsClick = usePostImpressionsModal(post);
const {
enabled: impressionsEnabled,
showImpressions,
impressions,
} = usePostImpressions(post);

if (isFeedPreview) {
return null;
}
Expand All @@ -135,13 +154,16 @@ const ActionButtonsV1 = ({
href={post.commentsPermalink}
pressed={post.commented}
variant={ButtonVariant.Tertiary}
size={config.buttonSize}
icon={<CommentIcon secondary={post.commented} size={config.iconSize} />}
size={buttonSize}
icon={<CommentIcon secondary={post.commented} size={iconSize} />}
onClick={() => onCommentClick?.(post)}
>
{commentCount > 0 && (
<InteractionCounter
className={classNames('tabular-nums', !commentCount && 'invisible')}
className={classNames(
counterClassName,
!commentCount && 'invisible',
)}
value={commentCount}
/>
)}
Expand All @@ -152,16 +174,16 @@ const ActionButtonsV1 = ({
<QuaternaryButton
labelClassName="!pl-[1px]"
id={`post-${post.id}-comment-btn`}
icon={<CommentIcon secondary={post.commented} size={config.iconSize} />}
icon={<CommentIcon secondary={post.commented} size={iconSize} />}
pressed={post.commented}
onClick={() => onCommentClick?.(post)}
size={config.buttonSize}
size={buttonSize}
className="btn-tertiary-blueCheese"
>
{commentCount > 0 && (
<InteractionCounter
className={classNames(
'tabular-nums !typo-footnote',
counterClassName,
!commentCount && 'invisible',
)}
value={commentCount}
Expand Down Expand Up @@ -192,26 +214,24 @@ const ActionButtonsV1 = ({
pressed={isUpvoteActive}
onClick={onToggleUpvote}
variant={ButtonVariant.Tertiary}
size={config.buttonSize}
size={buttonSize}
icon={
<UpvoteButtonIcon
secondary={isUpvoteActive}
size={config.iconSize}
size={iconSize}
brandAnimation={brandAnimation}
/>
}
>
{upvoteCount > 0 && (
<InteractionCounter
className={classNames(
'tabular-nums',
variant === 'grid' && 'typo-footnote',
)}
className={counterClassName}
value={upvoteCount}
/>
)}
</QuaternaryButton>
</Tooltip>
{commentButton}
{showDownvoteAction && (
<Tooltip
content={isDownvoteActive ? 'Remove downvote' : 'Downvote'}
Expand All @@ -222,29 +242,28 @@ const ActionButtonsV1 = ({
id={`post-${post.id}-downvote-btn`}
color={ButtonColor.Ketchup}
icon={
<DownvoteIcon
secondary={isDownvoteActive}
size={config.iconSize}
/>
<DownvoteIcon secondary={isDownvoteActive} size={iconSize} />
}
pressed={isDownvoteActive}
onClick={onToggleDownvote}
variant={ButtonVariant.Tertiary}
size={config.buttonSize}
size={buttonSize}
/>
</Tooltip>
)}
{commentButton}
{showAwardAction && (
<PostAwardAction post={post} iconSize={config.iconSize} />
{/* 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) && (
<PostAwardAction post={post} iconSize={iconSize} />
)}
<BookmarkButton
tooltipSide={variant === 'grid' ? 'bottom' : undefined}
post={post}
buttonProps={{
id: `post-${post.id}-bookmark-btn`,
onClick: onToggleBookmark,
size: config.buttonSize,
size: buttonSize,
className: classNames(
'btn-tertiary-bun',
variant === 'list' && 'pointer-events-auto',
Expand All @@ -253,22 +272,48 @@ const ActionButtonsV1 = ({
variant: ButtonVariant.Tertiary,
}),
}}
iconSize={config.iconSize}
iconSize={iconSize}
/>
<Tooltip
content="Copy link"
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
id="copy-post-btn"
size={config.buttonSize}
icon={<LinkIcon size={config.iconSize} />}
size={buttonSize}
icon={<LinkIcon size={iconSize} />}
onClick={onCopyLink}
variant={ButtonVariant.Tertiary}
color={ButtonColor.Cabbage}
className={variant === 'list' ? 'pointer-events-auto' : undefined}
/>
</Tooltip>
{showImpressions && (
<Tooltip
content="Impressions"
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
labelClassName={variant === 'grid' ? '!pl-[1px]' : '!pl-0'}
id={`post-${post.id}-impressions-btn`}
size={buttonSize}
icon={<AnalyticsIcon size={iconSize} />}
onClick={onImpressionsClick}
variant={ButtonVariant.Tertiary}
color={ButtonColor.Cheese}
className={classNames(
'btn-tertiary-cheese',
variant === 'list' && 'pointer-events-auto',
)}
>
<InteractionCounter
className={counterClassName}
value={impressions}
format={formatImpressions}
/>
</QuaternaryButton>
</Tooltip>
)}
</div>
</div>
);
Expand Down
Loading
Loading