Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
60cff1b
fix(post): make opening the source article an easy target
tsahimatsliah Jun 28, 2026
c5eaa97
fix(post): drop cover image zoom badge
tsahimatsliah Jun 28, 2026
2e07f15
fix(post): link author name to profile in focus card
tsahimatsliah Jun 28, 2026
2ca208f
fix(post): keep read button content-width on mobile
tsahimatsliah Jun 28, 2026
7aa4431
feat(post): make the whole lead area open the article
tsahimatsliah Jun 28, 2026
fda633f
feat(post): add hover highlight to the lead click area
tsahimatsliah Jun 28, 2026
52f5f89
feat(post): underline title on lead-area hover instead of bg tint
tsahimatsliah Jun 28, 2026
737bc2e
style(post): enlarge cover thumbnail ~20%
tsahimatsliah Jun 28, 2026
3a0ef9a
style(post): rotate the three-dots menu icon 90deg
tsahimatsliah Jun 28, 2026
f92dcf0
feat(post): turn title link-colour on lead-area hover
tsahimatsliah Jun 28, 2026
ab82530
fix(post): underline source name only when hovering the text
tsahimatsliah Jun 28, 2026
883728c
feat(post): exclude image hover from title colour; match image height
tsahimatsliah Jun 28, 2026
71186b8
feat(post): make the "From {domain}" link hover blue + underline
tsahimatsliah Jun 28, 2026
26895ae
feat(post): limit post link to title + read button
tsahimatsliah Jun 29, 2026
df43bc4
feat(post): add flat text link to the source after the summary
tsahimatsliah Jun 29, 2026
0d4dde0
style(post): match source text link to the metadata line
tsahimatsliah Jun 29, 2026
3f8af42
copy(post): source link reads "Read the full article on {domain}"
tsahimatsliah Jun 29, 2026
fd5ef5c
style(post): source link — tertiary text, blue underlined domain
tsahimatsliah Jun 29, 2026
0899ef1
style(post): square cover below tablet, stretch from tablet up
tsahimatsliah Jun 29, 2026
7a2e620
feat(post): full-width read bar; move metadata under the title
tsahimatsliah Jun 29, 2026
3e717b5
feat(post): flatten read CTA; add Storybook variants
tsahimatsliah Jun 29, 2026
cf98859
chore(storybook): layout-led read CTA variants
tsahimatsliah Jun 29, 2026
b5b163f
chore(storybook): refine read CTA directions A, B, E
tsahimatsliah Jun 29, 2026
2a6ee50
chore(storybook): converge read CTA on primary-tile list row
tsahimatsliah Jun 29, 2026
e487e9c
chore(storybook): primary tile row with interface-feel motion
tsahimatsliah Jun 29, 2026
556c734
feat(post): ship primary-tile read CTA with hover motion
tsahimatsliah Jun 30, 2026
052fb0b
chore(storybook): add "M2 variations" — ten tile-pop takes, no source…
tsahimatsliah Jun 30, 2026
1ef602c
feat(post): read CTA as content-width primary block with subtitle
tsahimatsliah Jun 30, 2026
069431a
feat(post): soften read CTA label + add "05 explorations"
tsahimatsliah Jun 30, 2026
9ffbf67
fix(post): keep CTA title bold, mute the source line
tsahimatsliah Jun 30, 2026
3b10e60
feat(post): single-line read CTA, icon on the right
tsahimatsliah Jun 30, 2026
027bb0a
style(post): shrink read CTA one size down
tsahimatsliah Jun 30, 2026
e124b6e
style(post): prettier wrap read CTA label
tsahimatsliah Jun 30, 2026
94206be
feat(post): read CTA above the TL;DR on mobile
tsahimatsliah Jun 30, 2026
dbeb9a6
fix(post): wrap source domain on mobile instead of truncating
tsahimatsliah Jun 30, 2026
5c48828
fix(post): even out spacing around the metadata line
tsahimatsliah Jun 30, 2026
7383bd3
feat(post): float engagement bar at the bottom, not the top
tsahimatsliah Jul 1, 2026
4225b3f
style(post): full-width engagement bar with evenly spaced actions
tsahimatsliah Jul 1, 2026
0bd2d47
feat(post): YouTube-style engagement pills
tsahimatsliah Jul 1, 2026
83d55f2
fix(post): show the sharer's commentary on share posts
tsahimatsliah Jul 1, 2026
bcd29dc
style(post): square off engagement pills to match our buttons
tsahimatsliah Jul 1, 2026
13e1e10
fix(post): keep cover at OG ratio on desktop, square on mobile
tsahimatsliah Jul 1, 2026
3ac657f
feat(post): Save label + Reddit-style vote pill
tsahimatsliah Jul 1, 2026
d200e00
feat(post): split engagement bar into contribution vs utility sides
tsahimatsliah Jul 1, 2026
130b52b
fix(post): Save action as CardAction to stop label clipping
tsahimatsliah Jul 1, 2026
4bd8ae0
feat(post): utility side — drop menu, swap save/copy, icon-only on mo…
tsahimatsliah Jul 1, 2026
f943336
fix(post): taller pills; true icon-only Save/Copy on mobile
tsahimatsliah Jul 1, 2026
edbc7a1
fix(post): restore the Boost button on the redesign card
tsahimatsliah Jul 1, 2026
01993e1
feat(post): move date strip directly under the title
tsahimatsliah Jul 1, 2026
ea647bc
style(post): space above/below the action bar, not inside the pills
tsahimatsliah Jul 1, 2026
b3548c7
feat(post): group each side of the action bar in one container
tsahimatsliah Jul 1, 2026
5b9e3c5
style(post): make the two action groups floating glass bars
tsahimatsliah Jul 1, 2026
cd2a343
style(post): single full-width floating action bar
tsahimatsliah Jul 1, 2026
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
306 changes: 87 additions & 219 deletions packages/shared/src/components/post/focus/FocusCardActionBar.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import type { ReactElement } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { Post } from '../../../graphql/posts';
import { UserVote } from '../../../graphql/posts';
import { useViewSize, useVotePost, ViewSize } from '../../../hooks';
import { useVotePost } from '../../../hooks';
import { useBookmarkPost } from '../../../hooks/useBookmarkPost';
import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel';
import { useCanAwardUser } from '../../../hooks/useCoresFeature';
import { useLazyModal } from '../../../hooks/useLazyModal';
import { LazyModal } from '../../modals/common/types';
import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant';
import { useAuthContext } from '../../../contexts/AuthContext';
import type { PostOrigin } from '../../../hooks/log/useLogContextData';
import { Origin } from '../../../lib/log';
import { AuthTriggers } from '../../../lib/auth';
import { ButtonSize } from '../../buttons/Button';
import { ButtonColor } from '../../buttons/ButtonV2';
import { CardAction } from '../../buttons/CardAction';
import { BookmarkButton } from '../../buttons/BookmarkButton';
import CloseButton from '../../CloseButton';
import { UpvoteButtonIcon } from '../../cards/common/UpvoteButtonIcon';
import { IconSize } from '../../Icon';
import {
BookmarkIcon,
DiscussIcon as CommentIcon,
DownvoteIcon,
LinkIcon,
Expand All @@ -30,34 +26,29 @@ import {
import { Tooltip } from '../../tooltip/Tooltip';
import type { LoggedUser } from '../../../lib/user';
import { PostClickbaitShield } from '../common/PostClickbaitShield';
import { PostMenuOptions } from '../PostMenuOptions';

interface FocusCardActionBarProps {
post: Post;
origin?: PostOrigin;
onComment?: () => void;
onCopyLinkClick?: (post?: Post) => void;
/** When provided (post modal), renders an X close button next to the menu. */
onClose?: () => void;
className?: string;
}

/**
* Engagement bar for the redesign focus card, built on the CardAction
* primitives (PR #6064 guideline): each action's count lives inside the click
* target so the icon or number performs the action. Sticks to the top while
* scrolling; the modal X appears only once the bar is pinned.
* Engagement bar for the redesign focus card: one full-width floating glass bar
* (translucent, blurred, soft shadow) with post-contribution actions on the
* left and utility actions on the right. Floats pinned to the BOTTOM of the
* scroll area on tablet up (never the top).
*/
export const FocusCardActionBar = ({
post,
origin = Origin.ArticlePage,
onComment,
onCopyLinkClick,
onClose,
className,
}: FocusCardActionBarProps): ReactElement => {
const { user, showLogin } = useAuthContext();
const { isV2 } = useLayoutVariant();
const { toggleUpvote, toggleDownvote } = useVotePost();
const { toggleBookmark } = useBookmarkPost();
const { onShowPanel, onClose: onCloseBlockPanel } = useBlockPostPanel(post);
Expand All @@ -67,106 +58,9 @@ export const FocusCardActionBar = ({
receivingUser: post.author as LoggedUser | undefined,
});

// Track whether the bar is pinned, and at which edge. The sentinel sits just
// above the bar: when it scrolls above the viewport top the bar is pinned at
// the TOP; when it's still below the viewport the bar is floating at the
// BOTTOM. The modal's X is only useful at the top (where the top nav strip
// has scrolled away) — at the bottom that strip is still on screen.
const sentinelRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const copyLinkRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
const [isStuckTop, setIsStuckTop] = useState(false);
useEffect(() => {
const el = sentinelRef.current;
if (!el || typeof IntersectionObserver === 'undefined') {
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => {
const stuck = !entry.isIntersecting;
setIsStuck(stuck);
const rootTop = entry.rootBounds?.top ?? 0;
setIsStuckTop(stuck && entry.boundingClientRect.top < rootTop);
},
{ threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);

const isUpvoteActive = post?.userState?.vote === UserVote.Up;
const isDownvoteActive = post?.userState?.vote === UserVote.Down;
const isAwarded = !!post?.userState?.awarded;
// Counts are hidden in the resting bar (the stats row sitting right above it
// already shows them) and surface only once the bar is pinned and that row
// has scrolled away.
const upvotes = post.numUpvotes || 0;
const comments = post.numComments || 0;
const awards = post.numAwards || 0;
// The bar floats (sticky) from tablet up, so surface the metrics + menu
// whenever it's actually pinned there — including when a long post floats it
// at the bottom on load, where the stats row above has scrolled off. Below
// tablet the bar is plain in-flow, so keep it stable (no counts) — that's the
// width where toggling on scroll looked like flicker.
const barFloats = useViewSize(ViewSize.Tablet);
const isPinned = isStuck && barFloats;
// The X (modal close) only makes sense when pinned at the top; at the bottom
// the modal's top strip — and its own close — are still on screen.
const isPinnedTop = isStuckTop && barFloats;
// Sticky at BOTH edges (`top` + `bottom`), tablet and up only — on mobile the
// dedicated floating bottom bar already covers this, so the desktop treatment
// is excluded there. While its natural spot is still below the fold the bar
// pins near the bottom (always reachable), scrolls naturally through the
// viewport, then pins near the top once it scrolls above. `top-4`/`bottom-4`
// leave a gap from each edge so the pill reads as floating. The top offset
// also accounts for the top chrome — the modal has no app header; on the post
// page the v2 rail hides the global header on laptop for logged-in users, so
// the bar floats near the top, while the legacy/logged-out layout must clear
// a fixed 4rem header (4rem + 1rem gap = top-20). `onClose` is modal-only.
const railOwnsHeader = isV2 && !!user;
const stickyOffsetClassName =
onClose || railOwnsHeader
? 'tablet:top-4 tablet:bottom-4'
: 'tablet:top-4 tablet:bottom-4 laptop:top-20';

// Fold copy link out of the row when the bar would overflow, and bring it
// back inline when there is room again. Measured against the real available
// width — not breakpoints — so it reacts to page/modal resizing.
useEffect(() => {
const bar = barRef.current;
if (!bar) {
return undefined;
}
const fit = () => {
const copyLink = copyLinkRef.current;
// Show first (inline display overrides the SSR fallback classes), then
// hide it if the row still overflows.
if (copyLink) {
copyLink.style.display = 'flex';
}
const overflows = () => bar.scrollWidth > bar.clientWidth;
if (copyLink && overflows()) {
copyLink.style.display = 'none';
}
};
fit();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(fit);
observer.observe(bar);
return () => observer.disconnect();
// isPinned/counts change the row width (counts + "…" menu appear when pinned).
}, [
canAward,
post.clickbaitTitleDetected,
post.bookmarked,
isPinned,
upvotes,
comments,
awards,
]);

const onToggleUpvote = async () => {
if (post?.userState?.vote === UserVote.None) {
Expand Down Expand Up @@ -211,117 +105,91 @@ export const FocusCardActionBar = ({
};

return (
<>
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-0" />
<div
ref={barRef}
className={classNames(
// Same floating-pill design on every resolution (incl. the
// translucent surface): rounded, blur, soft shadow, full border.
// Sticky from tablet up only — on mobile it stays in-flow, since the
// dedicated footer floating bar handles the pinned behavior there.
'relative z-3 flex items-center justify-between gap-2 rounded-16 border border-border-subtlest-tertiary bg-surface-float px-2 py-1 shadow-[0_0.25rem_1.5rem_0_var(--theme-shadow-shadow1)] backdrop-blur-[2.5rem] tablet:sticky',
stickyOffsetClassName,
className,
)}
>
<div className="flex items-center gap-1">
<Tooltip content={isUpvoteActive ? 'Remove upvote' : 'Upvote'}>
<CardAction
id="upvote-post-btn"
label="Upvote"
color={ButtonColor.Avocado}
icon={<UpvoteButtonIcon />}
iconPressed={<UpvoteButtonIcon secondary />}
count={isPinned ? upvotes : undefined}
pressed={isUpvoteActive}
onClick={onToggleUpvote}
/>
</Tooltip>
<Tooltip content={isDownvoteActive ? 'Remove downvote' : 'Downvote'}>
<CardAction
id="downvote-post-btn"
label="Downvote"
color={ButtonColor.Ketchup}
icon={<DownvoteIcon />}
iconPressed={<DownvoteIcon secondary />}
pressed={isDownvoteActive}
onClick={onToggleDownvote}
/>
</Tooltip>
<Tooltip content="Comment">
<div
className={classNames(
// One full-width floating glass bar (macOS/iOS style): translucent,
// blurred, soft shadow. Post-contribution actions on the left, utility
// actions on the right. Floats pinned to the bottom from tablet up
// (never the top).
'relative z-3 flex w-full items-center justify-between gap-2 rounded-16 border border-border-subtlest-tertiary bg-surface-float px-2 py-1 shadow-[0_0.25rem_1.5rem_0_var(--theme-shadow-shadow1)] backdrop-blur-[2.5rem] tablet:sticky tablet:bottom-4',
className,
)}
>
<div className="flex items-center gap-1">
<Tooltip content={isUpvoteActive ? 'Remove upvote' : 'Upvote'}>
<CardAction
id="upvote-post-btn"
label="Upvote"
color={ButtonColor.Avocado}
icon={<UpvoteButtonIcon />}
iconPressed={<UpvoteButtonIcon secondary />}
pressed={isUpvoteActive}
onClick={onToggleUpvote}
/>
</Tooltip>
<Tooltip content={isDownvoteActive ? 'Remove downvote' : 'Downvote'}>
<CardAction
id="downvote-post-btn"
label="Downvote"
color={ButtonColor.Ketchup}
icon={<DownvoteIcon />}
iconPressed={<DownvoteIcon secondary />}
pressed={isDownvoteActive}
onClick={onToggleDownvote}
/>
</Tooltip>
<Tooltip content="Comment">
<CardAction
id="comment-post-btn"
label="Comment"
color={ButtonColor.BlueCheese}
icon={<CommentIcon />}
iconPressed={<CommentIcon secondary />}
pressed={post.commented}
onClick={onComment}
/>
</Tooltip>
{canAward && (
<Tooltip
content={isAwarded ? 'You already awarded this post!' : 'Award'}
>
<CardAction
id="comment-post-btn"
label="Comment"
color={ButtonColor.BlueCheese}
icon={<CommentIcon />}
iconPressed={<CommentIcon secondary />}
count={isPinned ? comments : undefined}
pressed={post.commented}
onClick={onComment}
id="award-post-btn"
label="Award"
color={ButtonColor.Cabbage}
icon={<MedalBadgeIcon secondary />}
iconPressed={<MedalBadgeIcon />}
pressed={isAwarded}
onClick={onGiveAward}
/>
</Tooltip>
{canAward && (
<Tooltip
content={isAwarded ? 'You already awarded this post!' : 'Award'}
>
<CardAction
id="award-post-btn"
label="Award"
color={ButtonColor.Cabbage}
icon={<MedalBadgeIcon secondary />}
iconPressed={<MedalBadgeIcon />}
count={isPinned ? awards : undefined}
pressed={isAwarded}
onClick={onGiveAward}
/>
</Tooltip>
)}
</div>
)}
</div>

<div className="flex items-center gap-1">
<BookmarkButton
post={post}
iconSize={IconSize.Small}
buttonProps={{
id: 'bookmark-post-btn',
pressed: post.bookmarked,
onClick: onToggleBookmark,
size: ButtonSize.Medium,
}}
<div className="flex items-center gap-1">
<Tooltip content={post.bookmarked ? 'Remove bookmark' : 'Bookmark'}>
<CardAction
id="bookmark-post-btn"
label={post.bookmarked ? 'Remove bookmark' : 'Bookmark'}
color={ButtonColor.Bun}
icon={<BookmarkIcon />}
iconPressed={<BookmarkIcon secondary />}
pressed={post.bookmarked}
onClick={onToggleBookmark}
/>
{/* Bookmark stays — it is the primary save action. Copy link folds
out when space is tight (see the overflow effect); the
`hidden tablet:flex` classes are only the pre-measurement (SSR)
fallback — the effect overrides display once it measures. The "…"
menu and analytics now live in the card header / stats row. */}
<div ref={copyLinkRef} className="hidden tablet:flex">
<Tooltip content="Copy link">
<CardAction
label="Copy link"
color={ButtonColor.Cabbage}
icon={<LinkIcon />}
onClick={() => onCopyLinkClick?.(post)}
/>
</Tooltip>
</div>
{post.clickbaitTitleDetected && (
<PostClickbaitShield post={post} iconOnly />
)}
{/* While pinned, the article header (which owns the "…" menu) has
scrolled away, so surface the menu here — to the left of the X. */}
{isPinned && (
<PostMenuOptions
post={post}
origin={origin}
buttonSize={ButtonSize.Medium}
/>
)}
{isPinnedTop && onClose && (
<CloseButton size={ButtonSize.Medium} onClick={() => onClose()} />
)}
</div>
</Tooltip>
<Tooltip content="Copy link">
<CardAction
label="Copy link"
color={ButtonColor.Cabbage}
icon={<LinkIcon />}
onClick={() => onCopyLinkClick?.(post)}
/>
</Tooltip>
{post.clickbaitTitleDetected && (
<PostClickbaitShield post={post} iconOnly />
)}
</div>
</>
</div>
);
};
Loading
Loading