diff --git a/packages/shared/src/components/InteractionCounter.tsx b/packages/shared/src/components/InteractionCounter.tsx index 84831e54f02..ed0c3e3e1e7 100644 --- a/packages/shared/src/components/InteractionCounter.tsx +++ b/packages/shared/src/components/InteractionCounter.tsx @@ -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); @@ -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 ( - - {largeNumberFormat(shownValue)} + + {format(shownValue)} ); } @@ -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 ( @@ -60,7 +71,7 @@ export default function InteractionCounter({ animate ? '-translate-y-full opacity-0' : 'translate-y-0 opacity-100', )} > - {largeNumberFormat(shownValue)} + {format(shownValue)} - {largeNumberFormat(value)} + {format(value)} ); diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx index f5e64df8b7d..a3e97475eb7 100644 --- a/packages/shared/src/components/buttons/CardAction.tsx +++ b/packages/shared/src/components/buttons/CardAction.tsx @@ -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; @@ -64,6 +66,7 @@ function CardActionComponent( iconPressed, label, count, + countFormat, color, pressed, loading, @@ -121,7 +124,9 @@ function CardActionComponent( {showLabel && ( {label} )} - {showCount && } + {showCount && ( + + )} )} diff --git a/packages/shared/src/components/buttons/QuaternaryButton.tsx b/packages/shared/src/components/buttons/QuaternaryButton.tsx index 84aa5c2c179..32a846d19ff 100644 --- a/packages/shared/src/components/buttons/QuaternaryButton.tsx +++ b/packages/shared/src/components/buttons/QuaternaryButton.tsx @@ -80,7 +80,9 @@ function QuaternaryButtonComponent( 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, )} diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index 8c9b93c65b1..74a10ce9d5e 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -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'; @@ -51,6 +52,12 @@ const IMAGE_COL_SPAN: Record = { 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, @@ -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({ + 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 }); @@ -276,9 +292,11 @@ export const ArticleFeaturedWideGridCard = forwardRef( showFeedback={false} />

{title} diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 83e2a65b5b2..88b6bbe21de 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -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'; @@ -21,6 +22,11 @@ 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 { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; import ActionButtonsV2 from './ActionButtons.v2'; @@ -78,6 +84,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 { @@ -114,6 +128,8 @@ const ActionButtonsV1 = ({ }; }, [getUpvoteAnimation, post.tags]); + const onImpressionsClick = usePostImpressionsModal(post); + if (isFeedPreview) { return null; } @@ -135,13 +151,16 @@ const ActionButtonsV1 = ({ href={post.commentsPermalink} pressed={post.commented} variant={ButtonVariant.Tertiary} - size={config.buttonSize} - icon={} + size={buttonSize} + icon={} onClick={() => onCommentClick?.(post)} > {commentCount > 0 && ( )} @@ -152,16 +171,16 @@ const ActionButtonsV1 = ({ } + icon={} pressed={post.commented} onClick={() => onCommentClick?.(post)} - size={config.buttonSize} + size={buttonSize} className="btn-tertiary-blueCheese" > {commentCount > 0 && ( } > {upvoteCount > 0 && ( )} + {commentButton} {showDownvoteAction && ( + } pressed={isDownvoteActive} onClick={onToggleDownvote} variant={ButtonVariant.Tertiary} - size={config.buttonSize} + size={buttonSize} /> )} - {commentButton} - {showAwardAction && ( - + {showAwardAction && isLaptop && ( + )} } + size={buttonSize} + icon={} onClick={onCopyLink} variant={ButtonVariant.Tertiary} color={ButtonColor.Cabbage} className={variant === 'list' ? 'pointer-events-auto' : undefined} /> + + } + onClick={onImpressionsClick} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + className={classNames( + 'btn-tertiary-cheese', + variant === 'list' && 'pointer-events-auto', + )} + > + + + ); diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 9c5063f5fa2..940c222a681 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -5,12 +5,13 @@ import type { Post } from '../../../graphql/posts'; import { CardAction } from '../../buttons/CardAction'; import { CardActionBar } from '../../buttons/CardActionBar'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, LinkIcon, DownvoteIcon, } from '../../icons'; import { ButtonColor } from '../../buttons/ButtonV2'; -import { useFeedPreviewMode } from '../../../hooks'; +import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -20,6 +21,11 @@ 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 { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; export type ActionButtonsVariant = 'grid' | 'list' | 'signal'; @@ -70,6 +76,8 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); + // Awards are hidden in the feed below laptop (tablet + mobile). + const isLaptop = useViewSize(ViewSize.Laptop); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -105,6 +113,8 @@ const ActionButtons = ({ }; }, [getUpvoteAnimation, post.tags]); + const onImpressionsClick = usePostImpressionsModal(post); + if (isFeedPreview) { return null; } @@ -175,6 +185,7 @@ const ActionButtons = ({ buttonClassName="pointer-events-auto" /> + {commentButton} {showDownvoteAction && ( )} - {commentButton} - {showAwardAction && ( + {showAwardAction && isLaptop && ( )} + + } + label="Impressions" + count={getPostImpressions(post)} + countFormat={formatImpressions} + onClick={onImpressionsClick} + color={ButtonColor.Cheese} + buttonClassName={classNames( + variant === 'list' && 'pointer-events-auto', + )} + /> + ); diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index fc1510c4d09..ae247cd24d3 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -7,6 +7,7 @@ import InteractionCounter from '../../InteractionCounter'; import { QuaternaryButton } from '../../buttons/QuaternaryButton'; import { BookmarkButton } from '../../buttons/BookmarkButton'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -16,6 +17,11 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; +import { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -32,10 +38,14 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // actions evenly across the pill: the icons keep equal gaps and a long counter // (e.g. 900 upvotes / 900 comments) just grows its own button instead of // clipping or shoving a neighbour off its mark. -// `px-1` matches the 4px the h-10 pill leaves above/below its h-8 buttons, so -// the padding is equal on all four sides. +// The pill stays h-10 (its original height) so it reads as a comfortable bar, +// while the buttons themselves are the smaller XSmall size. +// Asymmetric `pl-1 pr-2.5`: the left edge holds the upvote icon while the right +// edge holds the impressions number — a number reads tighter against the edge +// than an icon, so it needs more padding to look optically balanced (per +// jakub.kr "details that make interfaces feel better"). const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden pl-1 pr-2.5', 'rounded-12 border border-border-subtlest-tertiary', 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', @@ -49,9 +59,10 @@ const pillClasses = classNames( const glassBackground = 'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--theme-blur-blur-bg)'; -// Keep the counter compact: monospaced digits so it never jitters, and the -// `pr-1` breathing room mirrors the standard grid action bar. -const countLabelClasses = '!pl-0.5 pr-1'; +// Keep the counter compact: monospaced digits so it never jitters, and a hair +// of padding on each side so six actions (incl. impressions) still fit a narrow +// card without the trailing icon clipping. +const countLabelClasses = '!pl-0.5 pr-0.5'; const countClasses = 'tabular-nums typo-footnote'; // Dark glow behind the pill so it stays readable over busy cover images. Fixed @@ -86,6 +97,7 @@ export function FeedCardGlassActions({ onBookmarkClick, onCopyLinkClick, }); + const onImpressionsClick = usePostImpressionsModal(post); if (isFeedPreview) { return null; @@ -93,6 +105,9 @@ export function FeedCardGlassActions({ const upvoteCount = post.numUpvotes ?? 0; const commentCount = post.numComments ?? 0; + // Impressions = per-post views, shown on every card as a public stat (mock + // fallback until the feed returns real views — see getPostImpressions). + const impressions = getPostImpressions(post); return ( <> @@ -117,11 +132,11 @@ export function FeedCardGlassActions({ pressed={isUpvoteActive} onClick={onToggleUpvote} variant={ButtonVariant.Tertiary} - size={ButtonSize.Small} + size={ButtonSize.XSmall} icon={ } > @@ -140,12 +155,12 @@ export function FeedCardGlassActions({ icon={ } pressed={post.commented} onClick={() => onCommentClick?.(post)} - size={ButtonSize.Small} + size={ButtonSize.XSmall} className="btn-tertiary-blueCheese pointer-events-auto" > {commentCount > 0 && ( @@ -168,13 +183,13 @@ export function FeedCardGlassActions({ icon={ } pressed={isDownvoteActive} onClick={onToggleDownvote} variant={ButtonVariant.Tertiary} - size={ButtonSize.Small} + size={ButtonSize.XSmall} /> )} @@ -184,22 +199,43 @@ export function FeedCardGlassActions({ buttonProps={{ id: `post-${post.id}-bookmark-btn`, onClick: onToggleBookmark, - size: ButtonSize.Small, + size: ButtonSize.XSmall, className: 'btn-tertiary-bun pointer-events-auto', }} - iconSize={IconSize.XSmall} + iconSize={IconSize.Size16} /> } + size={ButtonSize.XSmall} + icon={} onClick={onCopyLink} variant={ButtonVariant.Tertiary} color={ButtonColor.Cabbage} className="pointer-events-auto" /> + + } + size={ButtonSize.XSmall} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + onClick={onImpressionsClick} + className="btn-tertiary-cheese pointer-events-auto" + > + {/* Always render the impression count (even 0) so the number is + visible to everyone — unlike upvotes/comments it is not hidden + at zero. */} + + + diff --git a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx index 2f9ebceaf57..db6381eff0c 100644 --- a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx +++ b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx @@ -43,7 +43,9 @@ it('should render post metadata and engagement stats as passive metadata', () => expect(screen.getByText('Now')).toBeInTheDocument(); expect(screen.getByText('4m read time')).toBeInTheDocument(); - expect(screen.getByText('10 Impressions')).toBeInTheDocument(); + // Impressions are shown only on the full post page/modal stats strip, not in + // the compact embed. + expect(screen.queryByText(/Impressions?$/)).not.toBeInTheDocument(); expect(screen.getByText('1.2K Upvotes')).toBeInTheDocument(); expect(screen.getByText('3 Comments')).toBeInTheDocument(); expect(screen.getByText('5 Reposts')).toBeInTheDocument(); diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 199f5dfcce1..2934626328c 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -512,6 +512,13 @@ const ReaderPreviewLazyModal = dynamic( ), ); +const PostImpressionsModal = dynamic( + () => + import( + /* webpackChunkName: "postImpressionsModal" */ './post/PostImpressionsModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -595,6 +602,7 @@ export const modals = { [LazyModal.ReaderInstallPrompt]: ReaderInstallPromptModal, [LazyModal.ReaderExtensionInstall]: ReaderExtensionInstallModal, [LazyModal.ReaderPreview]: ReaderPreviewLazyModal, + [LazyModal.PostImpressions]: PostImpressionsModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index dfda6a0d09c..f28b066f111 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -107,6 +107,7 @@ export enum LazyModal { ReaderInstallPrompt = 'readerInstallPrompt', ReaderExtensionInstall = 'readerExtensionInstall', ReaderPreview = 'readerPreview', + PostImpressions = 'postImpressions', } export type ModalTabItem = { diff --git a/packages/shared/src/components/modals/post/PostImpressionsModal.tsx b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx new file mode 100644 index 00000000000..2233fbc9f4e --- /dev/null +++ b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Modal } from '../common/Modal'; +import type { LazyModalCommonProps } from '../common/Modal'; +import { ModalClose } from '../common/ModalClose'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + Typography, + TypographyTag, + TypographyType, + TypographyColor, +} from '../../typography/Typography'; +import { AnalyticsIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { docs } from '../../../lib/constants'; +import { anchorDefaultRel } from '../../../lib/strings'; + +/** + * X/Twitter-style "Views" explainer shown to non-authors when they tap the + * impressions stat — daily.dev's own modal styling, X-like messaging. + */ +function PostImpressionsModal(props: LazyModalCommonProps): ReactElement { + const { onRequestClose } = props; + + return ( + + + +
+ +
+ + Impressions + + + The number of times this post was seen across daily.dev. To learn + more, visit our{' '} + + docs + + . + + +
+
+ ); +} + +export default PostImpressionsModal; diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index 7e0ca893eb6..41f76a63506 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -8,18 +8,20 @@ import { Image } from '../image/Image'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useHasAccessToCores } from '../../hooks/useCoresFeature'; -import { canViewPostAnalytics } from '../../lib/user'; -import { useAuthContext } from '../../contexts/AuthContext'; import Link from '../utilities/Link'; -import { Button, ButtonSize } from '../buttons/Button'; -import { AnalyticsIcon } from '../icons'; import { webappUrl } from '../../lib/constants'; +import { getPostImpressions } from '../../lib/impressions'; const DEFAULT_REPOSTS_PER_PAGE = 20; type PostUpvotesCommentsCountPost = Pick< Post, - 'analytics' | 'numAwards' | 'numComments' | 'numReposts' | 'numUpvotes' + | 'analytics' + | 'numAwards' + | 'numComments' + | 'numReposts' + | 'numUpvotes' + | 'views' > & Partial> & { author?: Pick, 'id'>; @@ -43,7 +45,6 @@ interface PostUpvotesCommentsCountProps { type PostUpvotesCommentsCountContentProps = PostUpvotesCommentsCountProps & { onRepostsClick?: () => unknown; onAwardsClick?: () => unknown; - showPostAnalytics?: boolean; }; const PostUpvotesCommentsCountContent = ({ @@ -52,7 +53,6 @@ const PostUpvotesCommentsCountContent = ({ onCommentsClick, onRepostsClick, onAwardsClick, - showPostAnalytics = false, className, compact = false, passive = false, @@ -95,11 +95,6 @@ const PostUpvotesCommentsCountContent = ({ )} data-testid="statsBar" > - {!!post.analytics?.impressions && ( - - {getText({ count: post.analytics.impressions, label: 'Impression' })} - - )} {upvotes > 0 && renderText({ key: 'upvotes', @@ -112,6 +107,34 @@ const PostUpvotesCommentsCountContent = ({ onClick: onCommentsClick, children: getText({ count: comments, label: 'Comment' }), })} + {/* Impressions sit right after comments and look like the other stats; + tapping them opens the analytics page (replacing the old blue "Post + analytics" button). Shown on the post page/modal strip (not the + compact embed). Uses the shared impressions helper so it stays visible + via the mock fallback until the API populates real `views`. */} + {!compact && + post.id && + (() => { + const impressions = getPostImpressions({ + id: post.id, + views: post.views, + analytics: post.analytics, + }); + const label = getText({ count: impressions, label: 'Impression' }); + return !passive ? ( + + + {label} + + + ) : ( + {label} + ); + })()} {reposts > 0 && renderText({ key: 'reposts', @@ -136,18 +159,6 @@ const PostUpvotesCommentsCountContent = ({ ), })} - {showPostAnalytics && ( - - - - )} ); }; @@ -160,7 +171,6 @@ const InteractivePostUpvotesCommentsCount = ({ compact, }: PostUpvotesCommentsCountProps): ReactElement => { const { openModal } = useLazyModal(); - const { user } = useAuthContext(); const awards = post.numAwards || 0; const hasAccessToCores = useHasAccessToCores(); if (!post.id) { @@ -213,7 +223,6 @@ const InteractivePostUpvotesCommentsCount = ({ } : undefined } - showPostAnalytics={canViewPostAnalytics({ user, post })} className={className} compact={compact} /> diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 84082859142..881e8c92440 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -383,6 +383,7 @@ export const SHARED_POST_INFO_FRAGMENT = gql` analytics { impressions } + views numUpvotes numComments numAwards diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index c5ca42d45ee..1d37a10f537 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -261,6 +261,9 @@ export interface Post { numComments?: number; numAwards?: number; numReposts?: number; + // Per-post impressions, selected by the feed (FeedPostInfo) and post + // (SharedPostInfo) fragments. + views?: number; author?: Author; scout?: Scout; read?: boolean; diff --git a/packages/shared/src/hooks/post/usePostImpressionsModal.ts b/packages/shared/src/hooks/post/usePostImpressionsModal.ts new file mode 100644 index 00000000000..e6ff89499b8 --- /dev/null +++ b/packages/shared/src/hooks/post/usePostImpressionsModal.ts @@ -0,0 +1,38 @@ +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useRouter } from 'next/router'; +import type { Post } from '../../graphql/posts'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLazyModal } from '../useLazyModal'; +import { LazyModal } from '../../components/modals/common/types'; +import { canViewPostAnalytics } from '../../lib/user'; + +/** + * Click handler for the impressions stat: + * - the post owner (or a team member) can see real analytics, so they go to the + * post analytics page; + * - everyone else gets the X/Twitter-style explainer popup. + */ +export const usePostImpressionsModal = ( + post: Pick, +): ((event?: MouseEvent) => void) => { + const router = useRouter(); + const { user } = useAuthContext(); + const { openModal } = useLazyModal(); + const canViewAnalytics = canViewPostAnalytics({ user, post }); + + return useCallback( + (event?: MouseEvent) => { + event?.stopPropagation(); + event?.preventDefault(); + + if (canViewAnalytics) { + router.push(`/posts/${post.id}/analytics`); + return; + } + + openModal({ type: LazyModal.PostImpressions }); + }, + [canViewAnalytics, openModal, router, post.id], + ); +}; diff --git a/packages/shared/src/hooks/useFitFontSize.ts b/packages/shared/src/hooks/useFitFontSize.ts new file mode 100644 index 00000000000..3e191ba9eca --- /dev/null +++ b/packages/shared/src/hooks/useFitFontSize.ts @@ -0,0 +1,90 @@ +import type { RefCallback } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; + +// Layout effects must run before paint to avoid a flash of the wrong size, but +// `useLayoutEffect` warns during SSR — fall back to `useEffect` on the server. +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +interface UseFitFontSizeProps { + /** Re-measures whenever this changes (pass the rendered text). */ + text: string; + /** Font-size utility classes, largest first (e.g. typo-title1 → title3). */ + sizeClasses: string[]; + /** Maximum number of lines the text may occupy at the chosen size. */ + maxLines: number; +} + +interface UseFitFontSizeResult { + ref: RefCallback; + /** The largest size class at which the text fits within `maxLines`. */ + sizeClass: string; + /** + * True once the smallest size is reached and the text still overflows — the + * caller should clamp (ellipsis) as a last resort. While stepping down we + * leave the text unclamped so `scrollHeight` reflects its true height. + */ + isClamped: boolean; +} + +/** + * Shrinks text to the largest font size at which it still fits within + * `maxLines`, measuring the rendered element rather than guessing from + * character count (the container width varies, so a count heuristic can't be + * trusted). Steps down one size per layout pass until it fits or bottoms out. + * + * The element is tracked via a callback ref (state, not `useRef`) so the + * measure/observe effects re-run whenever the node actually attaches — e.g. + * when the title only mounts after a sibling panel (feedback card) is dismissed. + */ +export function useFitFontSize({ + text, + sizeClasses, + maxLines, +}: UseFitFontSizeProps): UseFitFontSizeResult { + const [node, setNode] = useState(null); + const [index, setIndex] = useState(0); + const lastIndex = sizeClasses.length - 1; + const ref = useCallback>((el) => setNode(el), []); + + // Start fresh from the largest size whenever the text changes. + useIsomorphicLayoutEffect(() => { + setIndex(0); + }, [text]); + + // After each applied size (and once the node attaches), measure and step + // down once if it still overflows. + useIsomorphicLayoutEffect(() => { + if (!node || index >= lastIndex) { + return; + } + const lineHeight = parseFloat(getComputedStyle(node).lineHeight); + if (!lineHeight) { + return; + } + if (Math.round(node.scrollHeight / lineHeight) > maxLines) { + setIndex((current) => Math.min(current + 1, lastIndex)); + } + }, [node, text, index, maxLines, lastIndex]); + + // Re-fit from scratch when the available width changes (height changes from + // our own shrinking are ignored so we don't loop). + useIsomorphicLayoutEffect(() => { + const parent = node?.parentElement; + if (!parent || typeof ResizeObserver === 'undefined') { + return undefined; + } + let lastWidth = parent.clientWidth; + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? lastWidth; + if (Math.abs(width - lastWidth) > 1) { + lastWidth = width; + setIndex(0); + } + }); + observer.observe(parent); + return () => observer.disconnect(); + }, [node]); + + return { ref, sizeClass: sizeClasses[index], isClamped: index >= lastIndex }; +} diff --git a/packages/shared/src/lib/impressions.ts b/packages/shared/src/lib/impressions.ts new file mode 100644 index 00000000000..036dc8392e7 --- /dev/null +++ b/packages/shared/src/lib/impressions.ts @@ -0,0 +1,58 @@ +import type { Post } from '../graphql/posts'; + +/** + * X/Twitter-style compact number: a decimal only shows while the abbreviated + * value is < 10 (1.2K, 9.9K, 1.8M) and is dropped once it reaches double digits + * (12K, 137K, 45M) — so the decimal point isn't displayed at every magnitude. + * Applied at all resolutions. + */ +export const formatImpressions = (value: number | null): string => { + if (value == null || !Number.isFinite(value)) { + return '0'; + } + if (Math.abs(value) < 1000) { + return String(Math.round(value)); + } + const units = ['K', 'M', 'B', 'T']; + let scaled = value; + let unit = -1; + while (Math.abs(scaled) >= 1000 && unit < units.length - 1) { + scaled /= 1000; + unit += 1; + } + const compact = + Math.abs(scaled) < 10 + ? (Math.round(scaled * 10) / 10).toFixed(1).replace(/\.0$/, '') + : String(Math.round(scaled)); + return `${compact}${units[unit]}`; +}; + +/** + * MOCK FALLBACK — per-post impressions are only populated in some payloads + * (`analytics.impressions` on the post detail for authors/team, `views` once + * the feed exposes it). When a real value is present we always use it; when it + * is missing we derive a stable, realistic-looking number from the post id + * purely so the impressions UI can be reviewed on a preview build. + * + * @engineer Replace this fallback with the real impressions field once the API + * exposes it everywhere — keep only the real-value branch. + */ +export const getPostImpressions = ( + post: Pick, +): number => { + const real = post.analytics?.impressions ?? post.views; + if (typeof real === 'number' && real > 0) { + return real; + } + + // Deterministic hash of the id → a stable number in ~100K–3M so each card + // shows a different realistic value (and it never changes between renders). + let hash = 0; + for (let i = 0; i < post.id.length; i += 1) { + hash = (hash * 131 + post.id.charCodeAt(i)) % 2_900_000; + } + // Final multiplicative mix so ids sharing a prefix (e.g. feed slugs) spread + // out instead of producing near-identical values. + hash = (hash * 1_000_003) % 2_900_000; + return 100_000 + hash; +}; diff --git a/packages/shared/src/lib/numberFormat.ts b/packages/shared/src/lib/numberFormat.ts index 1ed5599d89d..ce34cbce085 100644 --- a/packages/shared/src/lib/numberFormat.ts +++ b/packages/shared/src/lib/numberFormat.ts @@ -1,6 +1,6 @@ import type { PaddleProductLineItem } from '../graphql/paddle'; -export function largeNumberFormat(value: number): string | null { +export function largeNumberFormat(value: number | null): string | null { if (typeof value !== 'number') { return null; }