diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index fdcbe2cc6b6..c6cf3a03f12 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -19,6 +19,8 @@ import { ClickbaitShield } from '../common/ClickbaitShield'; import PostTags from '../common/PostTags'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const FreeformGrid = forwardRef(function SharePostCard( { @@ -42,6 +44,7 @@ export const FreeformGrid = forwardRef(function SharePostCard( const containerRef = useRef(); const image = usePostImage(post); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); if (isHidden) { @@ -79,6 +82,14 @@ export const FreeformGrid = forwardRef(function SharePostCard( + ) : undefined + } /> {title} @@ -111,6 +122,7 @@ export const FreeformGrid = forwardRef(function SharePostCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} className="mt-auto" onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 4abb8fcd9c2..b0b9dd1ec7f 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -26,6 +26,8 @@ import ActionButtons from '../common/ActionButtons'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ArticleGrid = forwardRef(function ArticleGrid( { @@ -48,6 +50,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( ): ReactElement { const { className, style } = domProps; const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const saveOnHover = useCardSaveOnHover(); const { data } = useBlockPostPanel(post); const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -130,6 +133,14 @@ export const ArticleGrid = forwardRef(function ArticleGrid( postLink={post.permalink} onReadArticleClick={onReadArticleClick} showFeedback={showFeedback} + bookmark={ + saveOnHover ? ( + + ) : undefined + } /> {title} @@ -168,6 +179,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> )} diff --git a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx index 2338b7ff68b..5f8fb3e988a 100644 --- a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx +++ b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { CollectionSourceStack } from '../../post/collection/CollectionSourceStack'; @@ -12,10 +12,13 @@ import type { Post } from '../../../graphql/posts'; interface CollectionCardHeaderProps { post: Post; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; } export const CollectionCardHeader = ({ post, + bookmark, }: CollectionCardHeaderProps): ReactElement => { const { collectionSources: sources, @@ -43,6 +46,7 @@ export const CollectionCardHeader = ({ totalSources={totalSources ?? 0} />
+ {bookmark} onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const saveOnHover = useCardSaveOnHover(); if (isHidden) { return ( @@ -94,7 +97,17 @@ export const CollectionGrid = forwardRef(function CollectionCard( onPostCardAuxClick={onPostCardAuxClick} /> - + + ) : undefined + } + /> diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index e642f8ce963..09433f68675 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -11,6 +11,8 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor, ButtonVariant } from '../../buttons/Button'; +import { ButtonSize } from '../../buttons/common'; +import { IconSize } from '../../Icon'; import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; @@ -29,6 +31,7 @@ import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; import ActionButtonsV2 from './ActionButtons.v2'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; import { getPostPath } from '../../../lib/links'; export type ActionButtonsVariant = 'grid' | 'list' | 'signal'; @@ -44,6 +47,12 @@ export interface ActionButtonsProps { /** Controls sizing and behavior. Grid = smaller icons, List = larger icons with link navigation */ variant?: ActionButtonsVariant; showDownvoteAction?: boolean; + /** + * `card_save_on_hover`: the card renders the bookmark in its header, so the + * bar leaves it out. Only grid cards that do so pass it; it is ignored when + * the flag is off. + */ + bookmarkInHeader?: boolean; } const variantConfig = { @@ -72,6 +81,14 @@ const variantConfig = { }, } as const; +interface ActionButtonsV1Props extends ActionButtonsProps { + /** + * `card_save_on_hover` treatment on grid cards: today's bar at 32px with + * 20px icons, in the same 36px row. + */ + large?: boolean; +} + const ActionButtonsV1 = ({ post, onUpvoteClick, @@ -82,10 +99,13 @@ const ActionButtonsV1 = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, -}: ActionButtonsProps): ReactElement | null => { + bookmarkInHeader = false, + large = false, +}: ActionButtonsV1Props): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - const { buttonSize, iconSize } = config; + const buttonSize = large ? ButtonSize.Small : config.buttonSize; + const iconSize = large ? IconSize.XSmall : config.iconSize; const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -186,7 +206,8 @@ const ActionButtonsV1 = ({
@@ -240,23 +261,25 @@ const ActionButtonsV1 = ({ /> )} - + {!bookmarkInHeader && ( + + )} { +const ActionButtons = (props: ActionButtonsProps): ReactElement | null => { + const { variant = 'grid', bookmarkInHeader, ...rest } = props; + // `card_save_on_hover` only changes grid cards, so only they evaluate it: + // list and signal renders must not enroll users who never see a change. + const saveOnHover = useCardSaveOnHover({ + shouldEvaluate: variant === 'grid', + }); const useV2 = useEngagementBarV2(); + // The treatment keeps the bar control renders (v1, or v2 for + // `engagement_bar_v2` users) and only changes its size and the bookmark's + // place, so the test measures those two things and not a bar swap. + const treatment = saveOnHover && variant === 'grid'; + const inHeader = treatment && !!bookmarkInHeader; if (useV2) { - return ; + return ( + + ); } - return ; + return ( + + ); }; export default ActionButtons; diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 30d8e9d3f30..ad335429cde 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { CardAction } from '../../buttons/CardAction'; +import type { CardActionDensity } from '../../buttons/CardAction'; import { CardActionBar } from '../../buttons/CardActionBar'; import { AnalyticsIcon, @@ -35,9 +36,20 @@ export interface ActionButtonsProps { onDownvoteClick?: (post: Post) => unknown; variant?: ActionButtonsVariant; showDownvoteAction?: boolean; + /** + * Button size. `tight` (24px) is the feed default; `card_save_on_hover` + * renders grid cards at `compact` (32px). + */ + density?: CardActionDensity; + /** The card renders the bookmark in its header, so the bar leaves it out. */ + bookmarkInHeader?: boolean; } -const FEED_CARD_DENSITY = 'tight'; +// `py-1.5` holds the grid row at 36px around 24px buttons; `py-0.5` does the +// same around 32px ones, so cards keep their height at either size. +const gridContainerClassName: Partial> = { + compact: 'px-2 py-0.5', +}; const variantConfig = { grid: { @@ -69,8 +81,13 @@ const ActionButtons = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, + density = 'tight', + bookmarkInHeader = false, }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; + const containerClassName = + (variant === 'grid' && gridContainerClassName[density]) || + config.containerClassName; const isFeedPreview = useFeedPreviewMode(); const { getUpvoteAnimation } = useBrandSponsorship(); @@ -123,7 +140,7 @@ const ActionButtons = ({ id={`post-${post.id}-comment-btn`} href={getPostPath(post)} pressed={post.commented} - density={FEED_CARD_DENSITY} + density={density} icon={} iconPressed={} label="Comment" @@ -137,7 +154,7 @@ const ActionButtons = ({ } iconPressed={} label="Comments" @@ -153,7 +170,7 @@ const ActionButtons = ({
@@ -164,7 +181,7 @@ const ActionButtons = ({ > } iconPressed={} @@ -196,23 +213,25 @@ const ActionButtons = ({ /> )} - + {!bookmarkInHeader && ( + + )} } label="Copy link" onClick={onCopyLink} @@ -229,7 +248,7 @@ const ActionButtons = ({ > } label="Impressions" count={impressions} diff --git a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx new file mode 100644 index 00000000000..ea80a0299b8 --- /dev/null +++ b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; +import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; +import { visibleOnGroupHover } from './common'; + +interface CardHeaderBookmarkProps { + post: Post; + onBookmarkClick?: (post: Post) => unknown; + className?: string; +} + +/** + * `card_save_on_hover`: the card's bookmark, in the header right before ⋯. + * Hidden at rest on desktop and shown while the card is hovered, like Read + * post and ⋯. Hidden elements are not in the tab order, so for keyboard users + * it appears once focus is already inside the card (the card's link or a bar + * button) and is reachable from there; Tab cannot land on it from outside. + * Always visible on touch screens. Saved or not, it stays hover-only on + * desktop so the header is empty at rest (product call). + */ +export const CardHeaderBookmark = ({ + post, + onBookmarkClick, + className, +}: CardHeaderBookmarkProps): ReactElement | null => { + const isFeedPreview = useFeedPreviewMode(); + const { onToggleBookmark } = useCardActions({ post, onBookmarkClick }); + + if (isFeedPreview) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index 8cae1f2fb18..7cb97bb4591 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -37,6 +37,8 @@ interface CardHeaderProps { flagProps?: FlagProps; showFeedback?: boolean; primaryAction?: ReactNode; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; } const Container = getGroupedHoverContainer('span'); @@ -51,6 +53,7 @@ export const PostCardHeader = ({ openNewTab, showFeedback, primaryAction, + bookmark, }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); const isSharedPostDeleted = post.sharedPost?.id === DeletedPostId; @@ -127,6 +130,7 @@ export const PostCardHeader = ({ icon={readPostIcon} /> ))} + {bookmark} )} diff --git a/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx b/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx index 22311d93668..d7606c42439 100644 --- a/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; @@ -12,10 +12,16 @@ import { AuthorSourceStack } from './AuthorSourceStack'; // enableSourceHeader is kept for API compatibility with the shared card props; // the source now always renders in the stack (when it isn't a user source). -type SquadPostCardHeaderProps = { post: Post; enableSourceHeader?: boolean }; +type SquadPostCardHeaderProps = { + post: Post; + enableSourceHeader?: boolean; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; +}; export const SquadPostCardHeader = ({ post, + bookmark, }: SquadPostCardHeaderProps): ReactElement => { const { author, source, bookmarked } = post; const { highlightBookmarkedPost } = useBookmarkProvider({ @@ -34,6 +40,7 @@ export const SquadPostCardHeader = ({
+ {bookmark} ({ + useRouter: () => ({ pathname: '/', query: {} }), +})); + +jest.mock('../../../hooks/cards/useCardSaveOnHover', () => ({ + useCardSaveOnHover: jest.fn(), +})); + +jest.mock('../../../hooks/post/usePostImpressions', () => ({ + usePostImpressions: jest.fn(), +})); + +jest.mock('../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +// Like the real hook: an unevaluated flag reads as control. +const setFlag = (on: boolean) => + jest + .mocked(useCardSaveOnHover) + .mockImplementation( + ({ shouldEvaluate = true } = {}) => on && shouldEvaluate, + ); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(false); + jest.mocked(usePostImpressions).mockReturnValue({ + showImpressions: true, + impressions: 1000, + canViewAnalytics: false, + onImpressionsClick: jest.fn(), + }); +}); + +const wrap = (children: React.ReactNode) => ( + {children} +); + +/** The bar's buttons, in order, by accessible name. */ +const barLabels = (container: HTMLElement): string[] => { + const upvote = container.querySelector('[id$="-upvote-btn"]'); + const bar = upvote?.closest('.justify-between'); + return Array.from(bar?.querySelectorAll('button, a[href]') ?? []) + .map((el) => el.getAttribute('aria-label')) + .filter((label): label is string => !!label); +}; + +describe('ActionButtons with card_save_on_hover', () => { + const renderBar = ( + variant: ActionButtonsVariant, + bookmarkInHeader?: boolean, + ) => + render( + wrap( + , + ), + ); + + it('keeps today’s order without bookmark when the card shows it in its header', () => { + setFlag(true); + const { container } = renderBar('grid', true); + + expect(barLabels(container)).toEqual([ + 'Upvote', + 'Comments', + 'Downvote', + 'Copy link', + 'Impressions', + ]); + }); + + it('keeps the bookmark in the bar for a grid card that has no header slot', () => { + setFlag(true); + const { container } = renderBar('grid'); + + expect(barLabels(container)).toEqual([ + 'Upvote', + 'Comments', + 'Downvote', + 'Bookmark', + 'Copy link', + 'Impressions', + ]); + }); + + it.each(['list', 'signal'])( + 'leaves %s cards at control, bookmark in the bar', + (variant) => { + setFlag(true); + const { container } = renderBar(variant); + + // Not evaluated, so these renders never enroll anyone. + expect(jest.mocked(useCardSaveOnHover)).toHaveBeenCalledWith({ + shouldEvaluate: false, + }); + + // Control is today's v1 bar: 24px buttons, the bookmark included. + expect(barLabels(container)).toContain('Bookmark'); + expect( + document.querySelector(`#post-${post.id}-upvote-btn`), + ).not.toHaveClass('h-8'); + }, + ); + + it('renders grid actions at 32px', () => { + setFlag(true); + renderBar('grid', true); + + expect(jest.mocked(useCardSaveOnHover)).toHaveBeenCalledWith({ + shouldEvaluate: true, + }); + expect(screen.getByRole('button', { name: 'Upvote' })).toHaveClass('h-8'); + }); + + it('keeps the bar control renders, only bigger', () => { + setFlag(true); + renderBar('grid', true); + + // Today's v1 bar, not the v2 CardAction bar. + expect(document.querySelector(`#post-${post.id}-upvote-btn`)).toHaveClass( + 'btn-tertiary-avocado', + ); + expect(document.querySelector('.card-action-content')).toBeNull(); + }); + + it('keeps the v2 bar for engagement_bar_v2 users, at 32px', () => { + jest.mocked(useEngagementBarV2).mockReturnValue(true); + setFlag(true); + const { container } = renderBar('grid', true); + + expect(document.querySelector('.card-action-content')).not.toBeNull(); + expect(document.querySelector(`#post-${post.id}-upvote-btn`)).toHaveClass( + 'h-8', + ); + expect(barLabels(container)).not.toContain('Bookmark'); + }); + + it('leaves today’s bar untouched when the flag is off', () => { + setFlag(false); + renderBar('grid', true); + + expect( + document.querySelector(`#post-${post.id}-upvote-btn`), + ).not.toHaveClass('h-8'); + expect(screen.getByRole('button', { name: 'Bookmark' })).toBeVisible(); + }); +}); + +const props = (cardPost: Post): PostCardProps => ({ + post: cardPost, + onPostClick: jest.fn(), + onPostAuxClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), +}); + +const gridCards: [string, ComponentType, Post][] = [ + ['article', ArticleGrid, post], + ['share', ShareGrid, sharePost], + [ + 'freeform', + FreeformGrid, + { ...post, type: PostType.Freeform, contentHtml: '

Post

' }, + ], + ['poll', PollGrid, pollPost], + [ + 'collection', + CollectionGrid, + { ...post, type: PostType.Collection, collectionSources: [] }, + ], + [ + 'social', + SocialTwitterGrid, + { ...sharePost, type: PostType.SocialTwitter, sharedPost: undefined }, + ], +]; + +describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { + it('moves the bookmark to the header when the flag is on', () => { + setFlag(true); + const cardProps = props(cardPost); + const { container } = render(wrap()); + + const bookmarks = screen.getAllByRole('button', { name: 'Bookmark' }); + expect(bookmarks).toHaveLength(1); + expect(barLabels(container)).not.toContain('Bookmark'); + + // Hidden at rest on desktop, like Read post and ⋯. + expect(bookmarks[0]).toHaveClass('laptop:mouse:invisible'); + + fireEvent.click(bookmarks[0]); + expect(cardProps.onBookmarkClick).toHaveBeenCalledWith( + expect.objectContaining({ id: cardPost.id }), + ); + }); + + it('keeps a saved post’s bookmark hover-only too', () => { + setFlag(true); + render(wrap()); + + const bookmark = screen.getByRole('button', { name: 'Remove bookmark' }); + expect(bookmark).toHaveClass('laptop:mouse:invisible'); + }); + + it('keeps the bookmark in the bar when the flag is off', () => { + setFlag(false); + render(wrap()); + + // Located by id: today's bar names the upvote button by its count. + const bar = document + .getElementById(`post-${cardPost.id}-upvote-btn`) + ?.closest('.justify-between') as HTMLElement; + expect( + within(bar).getByRole('button', { name: 'Bookmark' }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/cards/poll/PollGrid.tsx b/packages/shared/src/components/cards/poll/PollGrid.tsx index fcdb7d603ea..f2d398267a7 100644 --- a/packages/shared/src/components/cards/poll/PollGrid.tsx +++ b/packages/shared/src/components/cards/poll/PollGrid.tsx @@ -17,6 +17,8 @@ import CardOverlay from '../common/CardOverlay'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { usePollVote } from '../../../hooks/post/usePollVote'; import { isSourceSquadOrMachine } from '../../../graphql/sources'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const PollGrid = forwardRef(function PollCard( { @@ -35,6 +37,7 @@ const PollGrid = forwardRef(function PollCard( const { user } = useAuthContext(); const { handleVote, shouldAnimateResults } = usePollVote({ post }); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { pinnedAt, trending, pollOptions, endsAt, numPollVotes, source } = post; @@ -61,6 +64,14 @@ const PollGrid = forwardRef(function PollCard( + ) : undefined + } /> {title} @@ -91,6 +102,7 @@ const PollGrid = forwardRef(function PollCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index 5318356cb73..5a9b94dafeb 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -29,6 +29,8 @@ import { useFeature } from '../../GrowthBookProvider'; import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const EmptyStateContainer = classed( 'div', @@ -57,6 +59,7 @@ export const ShareGrid = forwardRef(function ShareGrid( const onPostCardAuxClick = () => onPostAuxClick(post); const containerRef = useRef(); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { sharedPost } = post; const isDeleted = sharedPost?.id === DeletedPostId; const { private: sharedPostPrivate, source: sharedPostSource } = @@ -192,6 +195,14 @@ export const ShareGrid = forwardRef(function ShareGrid( source={post.source} postLink={sharedPost?.permalink} onReadArticleClick={onReadArticleClick} + bookmark={ + saveOnHover ? ( + + ) : undefined + } /> {(!isSharedTweet || post.title) && {title}} @@ -219,6 +230,7 @@ export const ShareGrid = forwardRef(function ShareGrid( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index 8fd589b002d..169c4f291ab 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -35,6 +35,8 @@ import { useSocialTwitterCardData, } from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const HeaderActions = getGroupedHoverContainer('span'); @@ -54,6 +56,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ref: Ref, ): ReactElement { const isFeedPreview = useFeedPreviewMode(); + const saveOnHover = useCardSaveOnHover(); const isUserSource = isSourceUserSource(post.source); const rawTitle = post.title || post.sharedPost?.title; const { normalizedContent, hasDailyDevMarkdown, socialTextDirectionProps } = @@ -84,8 +87,8 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ); - const onPostCardClick = () => onPostClick(post); - const onPostCardAuxClick = () => onPostAuxClick(post); + const onPostCardClick = () => onPostClick?.(post); + const onPostCardAuxClick = () => onPostAuxClick?.(post); return ( )} + {saveOnHover && ( + + )}
@@ -175,6 +184,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +const setUser = (loggedIn: boolean) => + jest.mocked(useAuthContext).mockReturnValue({ + isAuthReady: true, + user: loggedIn ? { id: 'u1' } : undefined, + } as unknown as ReturnType); + +const evaluated = () => + jest.mocked(useConditionalFeature).mock.calls.at(-1)?.[0].shouldEvaluate; + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useConditionalFeature).mockReturnValue({ + value: true, + isLoading: false, + }); +}); + +describe('useCardSaveOnHover', () => { + it('evaluates the flag for a logged-in user', () => { + setUser(true); + const { result } = renderHook(() => useCardSaveOnHover()); + + expect(evaluated()).toBe(true); + expect(jest.mocked(useConditionalFeature)).toHaveBeenCalledWith( + expect.objectContaining({ feature: featureCardSaveOnHover }), + ); + expect(result.current).toBe(true); + }); + + it('never evaluates for anonymous visitors', () => { + setUser(false); + renderHook(() => useCardSaveOnHover()); + + expect(evaluated()).toBe(false); + }); + + it('does not evaluate where the caller says it cannot matter', () => { + setUser(true); + renderHook(() => useCardSaveOnHover({ shouldEvaluate: false })); + + expect(evaluated()).toBe(false); + }); +}); diff --git a/packages/shared/src/hooks/cards/useCardSaveOnHover.ts b/packages/shared/src/hooks/cards/useCardSaveOnHover.ts new file mode 100644 index 00000000000..25ed39e5a2c --- /dev/null +++ b/packages/shared/src/hooks/cards/useCardSaveOnHover.ts @@ -0,0 +1,27 @@ +import { useAuthContext } from '../../contexts/AuthContext'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { featureCardSaveOnHover } from '../../lib/featureManagement'; + +interface UseCardSaveOnHoverProps { + /** + * Evaluate only where the treatment changes something (grid cards), so + * users who never see a difference are not enrolled. + */ + shouldEvaluate?: boolean; +} + +/** + * `card_save_on_hover`: grid cards show the bookmark in their header on + * hover, and the action bar keeps today's order at 32px. Evaluated for + * logged-in users only, so anonymous visitors are never enrolled. + */ +export const useCardSaveOnHover = ({ + shouldEvaluate = true, +}: UseCardSaveOnHoverProps = {}): boolean => { + const { isAuthReady, user } = useAuthContext(); + const { value } = useConditionalFeature({ + feature: featureCardSaveOnHover, + shouldEvaluate: isAuthReady && !!user && shouldEvaluate, + }); + return value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index d1134a27bfc..862b644a2bc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -262,6 +262,11 @@ export const featureLayoutV2 = new Feature('layout_v2_2', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); +// Experiment: on grid cards the bookmark moves to the card header, shown on +// hover beside ⋯, and the bar keeps today's order at 32px with 20px icons. +// List cards keep the bookmark in the bar. Logged-in users only. +export const featureCardSaveOnHover = new Feature('card_save_on_hover', false); + export const featureHeroCards = new Feature('hero_cards', { enabled: false, minSpacing: 10, diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx new file mode 100644 index 00000000000..d1ed349aeb8 --- /dev/null +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import { featureCardSaveOnHover } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostTypeToGridCard } from '@dailydotdev/shared/src/components/cards/common/gridCards'; +import { PostTypeToListCard } from '@dailydotdev/shared/src/components/cards/common/listCards'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ArticleList } from '@dailydotdev/shared/src/components/cards/article/ArticleList'; +import post, { sharePost } from '@dailydotdev/shared/__tests__/fixture/post'; +import { pollPost } from '@dailydotdev/shared/__tests__/fixture/pollPost'; +import ExtensionProviders from '../extension/_providers'; +import { FeatureOverrides } from '../../mock/GrowthBookProvider'; + +/** + * `card_save_on_hover`, control vs treatment, on the production cards with + * the shared test fixtures. Treatment: grid cards show the bookmark in the + * header on hover (between Read post and ⋯, saved or not) and + * the bar keeps today's bar and order at 32px. List cards are not part of the + * experiment and stay at control. + */ + +const article: Post = { + ...post, + numUpvotes: 63, + numComments: 8, + analytics: { impressions: 197000 }, +} as Post; +const video: Post = { + ...article, + id: 'story-video', + type: PostType.VideoYouTube, + title: 'Shipping a design system in a week', +} as Post; +const freeform: Post = { + ...article, + id: 'story-freeform', + type: PostType.Freeform, + title: 'What I learned rewriting our feed in a weekend', + contentHtml: '

Notes from the rewrite.

', +} as Post; +const collection: Post = { + ...article, + id: 'story-collection', + type: PostType.Collection, + title: 'React Server Components, explained', + collectionSources: [post.source], + numCollectionSources: 3, +} as Post; + +const saved: Post = { + ...article, + id: 'story-saved', + title: 'A post you already saved', + bookmarked: true, +} as Post; + +const CARDS: { label: string; post: Post }[] = [ + { label: 'Article', post: article }, + { label: 'Saved article', post: saved }, + { label: 'Share', post: sharePost }, + { label: 'Post', post: freeform }, + { label: 'Video', post: video }, + { label: 'Poll', post: pollPost }, + { label: 'Collection', post: collection }, +]; + +const handlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const Flag = ({ on, children }: { on: boolean; children: ReactNode }) => ( + + {children} + +); + +/** Shows the hover state without a pointer: the header actions appear. */ +const forceHoverCss = ` +@media (pointer: fine) { + .force-hover .laptop\\:mouse\\:group-hover\\:visible { visibility: visible; } +}`; + +const Column = ({ + title, + note, + children, +}: { + title: string; + note?: string; + children: ReactNode; +}) => ( +
+
+

{title}

+ {note &&

{note}

} +
+ {children} +
+); + +interface GridArgs { + width: number; +} + +const GridCards = ({ width }: GridArgs): ReactElement => ( +
+ +
+ {CARDS.map(({ label, post }) => { + const Card = PostTypeToGridCard[post.type] ?? ArticleGrid; + return ( +
+

+ {label} +

+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+
+ ); + })} +
+
+); + +interface ListArgs { + show: 'both' | 'control' | 'treatment'; +} + +const ListCards = ({ show }: ListArgs): ReactElement => ( +
+
+ {[false, true] + .filter((on) => show === 'both' || (show === 'treatment') === on) + .map((on) => ( + + +
+ {CARDS.map(({ label, post }) => { + const Card = PostTypeToListCard[post.type] ?? ArticleList; + return ; + })} +
+
+
+ ))} +
+
+); + +const meta: Meta = { + title: 'Experiments/Card save on hover', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +export const Grid: StoryObj = { + name: 'Grid cards', + args: { width: 292 }, + argTypes: { + width: { control: { type: 'range', min: 272, max: 420, step: 4 } }, + }, + render: (args) => , +}; + +export const List: StoryObj = { + name: 'List cards (phone and tablet)', + args: { show: 'both' }, + argTypes: { + show: { + control: 'inline-radio', + options: ['both', 'control', 'treatment'], + }, + }, + render: (args) => , +};