From ce577eca02f3edbb6f4824f388bf59c7b4669eb8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 23:30:49 +0300 Subject: [PATCH 01/11] feat(post): redesigned reader (PostFocusCard) for modal + page Productionizes the post modal/page redesign from the #6130 mockup, scoped to the region from the top of the post down to the end of the comments, behind the post_discovery_experience flag (default off). - New discovery/ components: PostFocusCard (single-column reader), PostDiscussionPanel, DiscussionMetaBar, DiscussionShareRow, CollectionSources, and the pre-glass PostDiscoveryActionBar (the Liquid-Glass morphing bar is intentionally excluded). - Additive, backward-compatible props on shared components: SourceStrip (compact), PostSidebarAdWidget (inline variant), PostClickbaitShield (iconOnly), PostComments (removeTopSpacing), PostUpvotesCommentsCount (onCommentsClick), YoutubeVideo (autoplay). - usePostDiscoveryExperience gates the modal (ArticlePostModal/CollectionPostModal/ SharePostModal) and the post page; the page also honors a ?discovery=1/0 override for review. Classic layout, the For-you feed, signup hero and discovery feed are unchanged/out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/modals/ArticlePostModal.tsx | 49 +- .../components/modals/CollectionPostModal.tsx | 49 +- .../src/components/modals/SharePostModal.tsx | 65 ++- .../src/components/post/PostComments.tsx | 14 +- .../components/post/PostSidebarAdWidget.tsx | 94 ++++ .../post/PostUpvotesCommentsCount.tsx | 17 +- .../post/common/PostClickbaitShield.tsx | 67 ++- .../post/discovery/CollectionSources.tsx | 85 ++++ .../post/discovery/DiscussionMetaBar.tsx | 106 ++++ .../post/discovery/DiscussionShareRow.tsx | 181 +++++++ .../post/discovery/PostDiscoveryActionBar.tsx | 256 ++++++++++ .../post/discovery/PostDiscussionPanel.tsx | 231 +++++++++ .../post/discovery/PostFocusCard.tsx | 463 ++++++++++++++++++ .../components/post/reader/SourceStrip.tsx | 41 +- .../src/components/video/YoutubeVideo.tsx | 10 +- .../hooks/post/usePostDiscoveryExperience.ts | 42 ++ packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/tailwind.config.ts | 1 + packages/webapp/__tests__/PostPage.tsx | 51 ++ packages/webapp/pages/posts/[id]/index.tsx | 65 ++- 20 files changed, 1793 insertions(+), 98 deletions(-) create mode 100644 packages/shared/src/components/post/discovery/CollectionSources.tsx create mode 100644 packages/shared/src/components/post/discovery/DiscussionMetaBar.tsx create mode 100644 packages/shared/src/components/post/discovery/DiscussionShareRow.tsx create mode 100644 packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx create mode 100644 packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx create mode 100644 packages/shared/src/components/post/discovery/PostFocusCard.tsx create mode 100644 packages/shared/src/hooks/post/usePostDiscoveryExperience.ts diff --git a/packages/shared/src/components/modals/ArticlePostModal.tsx b/packages/shared/src/components/modals/ArticlePostModal.tsx index 5d02b93cf38..6c892d28c2f 100644 --- a/packages/shared/src/components/modals/ArticlePostModal.tsx +++ b/packages/shared/src/components/modals/ArticlePostModal.tsx @@ -9,6 +9,8 @@ import type { Post } from '../../graphql/posts'; import { PostType } from '../../graphql/posts'; import type { PassedPostNavigationProps } from '../post/common'; import { Origin } from '../../lib/log'; +import { usePostDiscoveryExperience } from '../../hooks/post/usePostDiscoveryExperience'; +import { PostFocusCard } from '../post/discovery/PostFocusCard'; interface ArticlePostModalProps extends ModalProps, PassedPostNavigationProps { id: string; @@ -29,12 +31,15 @@ export default function ArticlePostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { showDiscovery } = usePostDiscoveryExperience(post); return ( - + {showDiscovery ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + + )} ); } diff --git a/packages/shared/src/components/modals/CollectionPostModal.tsx b/packages/shared/src/components/modals/CollectionPostModal.tsx index 4407085abc6..544628ec33c 100644 --- a/packages/shared/src/components/modals/CollectionPostModal.tsx +++ b/packages/shared/src/components/modals/CollectionPostModal.tsx @@ -9,6 +9,8 @@ import type { Post } from '../../graphql/posts'; import { PostType } from '../../graphql/posts'; import type { PassedPostNavigationProps } from '../post/common'; import { CollectionPostContent } from '../post/collection'; +import { usePostDiscoveryExperience } from '../../hooks/post/usePostDiscoveryExperience'; +import { PostFocusCard } from '../post/discovery/PostFocusCard'; interface CollectionPostModalProps extends ModalProps, @@ -31,12 +33,15 @@ export default function CollectionPostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { showDiscovery } = usePostDiscoveryExperience(post); return ( - + {showDiscovery ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + + )} ); } diff --git a/packages/shared/src/components/modals/SharePostModal.tsx b/packages/shared/src/components/modals/SharePostModal.tsx index 96d4a6a1547..a05dc22d650 100644 --- a/packages/shared/src/components/modals/SharePostModal.tsx +++ b/packages/shared/src/components/modals/SharePostModal.tsx @@ -11,6 +11,8 @@ import { PostType } from '../../graphql/posts'; import EnableNotification from '../notifications/EnableNotification'; import { SquadPostContent } from '../post/SquadPostContent'; import { isSourceUserSource } from '../../graphql/sources'; +import { usePostDiscoveryExperience } from '../../hooks/post/usePostDiscoveryExperience'; +import { PostFocusCard } from '../post/discovery/PostFocusCard'; interface PostModalProps extends ModalProps, PassedPostNavigationProps { id: string; @@ -31,13 +33,15 @@ export default function PostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { showDiscovery } = usePostDiscoveryExperience(post); return ( - - + {showDiscovery ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + <> + + + + )} ); } diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index dfa0691187a..0071b6667cb 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -44,6 +44,12 @@ interface PostCommentsProps { onClickUpvote?: (commentId: string, upvotes: number) => unknown; className?: CommentClassName; onCommented?: MainCommentProps['onCommented']; + /** + * Drop the list's top margin. Use when comments are the first element in + * their container (e.g. the discovery discussion panel) so they don't get an + * extra gap above the first item. + */ + removeTopSpacing?: boolean; } const noopShare = (): void => {}; @@ -61,6 +67,7 @@ export function PostComments({ joinNotificationCommentId, className = {}, onCommented, + removeTopSpacing = false, }: PostCommentsProps): ReactElement { const { id } = post; const container = useRef(null); @@ -119,9 +126,12 @@ export function PostComments({ isModalThread ? classNames( 'mb-12 flex flex-col gap-4', - isComposerOpen ? 'mt-2' : 'mt-5', + !removeTopSpacing && (isComposerOpen ? 'mt-2' : 'mt-5'), + ) + : classNames( + '-mx-4 mb-12 flex flex-col gap-4 mobileL:mx-0', + !removeTopSpacing && 'mt-6', ) - : '-mx-4 mb-12 mt-6 flex flex-col gap-4 mobileL:mx-0' } ref={container} > diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index d6cb95e5688..d2fc64e140e 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -26,9 +26,16 @@ import { TypographyType, } from '../typography/Typography'; import { AdvertiseLink } from '../cards/ad/common/AdvertiseLink'; +import { Image } from '../image/Image'; interface PostSidebarAdWidgetProps { postId: string; + /** + * `card` (default) is the boxed sidebar widget. `inline` is a flat, + * borderless-background layout for in-content placements: the company name + * sits on the favicon line, with "Promoted" + the advertise link below it. + */ + variant?: 'card' | 'inline'; className?: { container?: string; }; @@ -36,6 +43,7 @@ interface PostSidebarAdWidgetProps { export function PostSidebarAdWidget({ postId, + variant = 'card', className, }: PostSidebarAdWidgetProps): ReactElement | null { const { user } = useAuthContext(); @@ -94,6 +102,92 @@ export function PostSidebarAdWidget({ size: 24, }); + if (variant === 'inline') { + const tagLine = ad.tagLine?.trim(); + const description = ad.description?.trim(); + // Always surface a title on the icon line: the company, else the + // tagline, else the description. Whatever is left over renders in the + // body — so a description-only ad has no extra body row. + const inlineTitle = company || tagLine || description; + const inlineBodyTagLine = company ? tagLine : undefined; + const inlineBodyDescription = company || tagLine ? description : undefined; + const inlineHasBody = !!inlineBodyTagLine || !!inlineBodyDescription; + + return ( +
+ onAdAction(AdActions.Click))} + /> +
+ {ad.source} +
+ {inlineTitle && ( + + {inlineTitle} + + )} +
+ + + · + + +
+
+ +
+ {inlineHasBody && ( + + {inlineBodyTagLine && {inlineBodyTagLine}} + {inlineBodyTagLine && inlineBodyDescription ? ' ' : ''} + {inlineBodyDescription} + + )} + + + +
+ ); + } + return ( unknown; + onCommentsClick?: () => unknown; className?: string; compact?: boolean; passive?: boolean; @@ -48,6 +49,7 @@ type PostUpvotesCommentsCountContentProps = PostUpvotesCommentsCountProps & { const PostUpvotesCommentsCountContent = ({ post, onUpvotesClick, + onCommentsClick, onRepostsClick, onAwardsClick, showPostAnalytics = false, @@ -104,12 +106,12 @@ const PostUpvotesCommentsCountContent = ({ onClick: () => onUpvotesClick?.(upvotes), children: getText({ count: upvotes, label: 'Upvote' }), })} - {comments > 0 && ( - - {largeNumberFormat(comments)} - {` Comment${comments === 1 ? '' : 's'}`} - - )} + {comments > 0 && + renderText({ + key: 'comments', + onClick: onCommentsClick, + children: getText({ count: comments, label: 'Comment' }), + })} {reposts > 0 && renderText({ key: 'reposts', @@ -153,6 +155,7 @@ const PostUpvotesCommentsCountContent = ({ const InteractivePostUpvotesCommentsCount = ({ post, onUpvotesClick, + onCommentsClick, className, compact, }: PostUpvotesCommentsCountProps): ReactElement => { @@ -165,6 +168,7 @@ const InteractivePostUpvotesCommentsCount = ({ @@ -192,6 +196,7 @@ const InteractivePostUpvotesCommentsCount = ({ 0 diff --git a/packages/shared/src/components/post/common/PostClickbaitShield.tsx b/packages/shared/src/components/post/common/PostClickbaitShield.tsx index 02b67abb71a..e4e2b6da74d 100644 --- a/packages/shared/src/components/post/common/PostClickbaitShield.tsx +++ b/packages/shared/src/components/post/common/PostClickbaitShield.tsx @@ -12,6 +12,7 @@ import { } from '../../../hooks'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { LazyModal } from '../../modals/common/types'; +import { IconSize } from '../../Icon'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import type { Post } from '../../../graphql/posts'; @@ -23,7 +24,13 @@ import { Typography, TypographyType } from '../../typography/Typography'; import { PostUpgradeToPlus } from '../../plus/PostUpgradeToPlus'; import { TargetId } from '../../../lib/log'; -export const PostClickbaitShield = ({ post }: { post: Post }): ReactElement => { +export const PostClickbaitShield = ({ + post, + iconOnly = false, +}: { + post: Post; + iconOnly?: boolean; +}): ReactElement => { const { openModal } = useLazyModal(); const { isPlus } = usePlusSubscription(); const { fetchSmartTitle, fetchedSmartTitle, shieldActive } = @@ -33,6 +40,64 @@ export const PostClickbaitShield = ({ post }: { post: Post }): ReactElement => { const { user } = useAuthContext(); const { hasUsedFreeTrial, triesLeft } = useClickbaitTries(); + if (iconOnly) { + const isActive = isPlus ? shieldActive : fetchedSmartTitle; + const handleIconClick = async () => { + if (isPlus || !hasUsedFreeTrial) { + await fetchSmartTitle(); + return; + } + + if (isMobile) { + openModal({ type: LazyModal.ClickbaitShield }); + return; + } + + if (!user) { + throw new Error( + 'PostClickbaitShield requires an authenticated user to edit feed settings', + ); + } + + router.push( + `${webappUrl}feeds/${user.id}/edit?dview=${FeedSettingsMenu.AI}`, + ); + }; + + const tooltipContent = (() => { + if (isActive) { + return 'Click to see the original title'; + } + return isPlus + ? 'Click to see the optimized title' + : 'Optimize this title with Clickbait Shield'; + })(); + + const renderIcon = () => { + if (isActive) { + return ; + } + if (isPlus) { + return ; + } + return ; + }; + + return ( + + + ); + + return ( +
+
+ } + shouldHandleCommentQuery + onComposerOpenChange={setIsComposerOpen} + size={ProfileImageSize.Medium} + CommentInputOrModal={CommentInputOrModal} + renderTrigger={renderComposerTrigger} + /> +
+ + {showSortHeader && ( + + + Sort: + + + + )} +
+ openShareComment(comment, post)} + onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} + modalParentSelector={resolveModalParent} + removeTopSpacing + /> +
+ {showMetaBar && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx new file mode 100644 index 00000000000..dc6630a717f --- /dev/null +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -0,0 +1,463 @@ +import dynamic from 'next/dynamic'; +import type { ComponentProps, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { + getReadArticleHref, + getReadPostButtonText, + isInternalReadType, + isVideoPost, + PostType, +} from '../../../graphql/posts'; +import type { SourceTooltip } from '../../../graphql/sources'; +import { SourceType } from '../../../graphql/sources'; +import type { PostOrigin } from '../../../hooks/log/useLogContextData'; +import usePostContent from '../../../hooks/usePostContent'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; +import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; +import PostMetadata from '../../cards/common/PostMetadata'; +import YoutubeVideo from '../../video/YoutubeVideo'; +import { PlayIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import Markdown from '../../Markdown'; +import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; +import { LazyImage } from '../../LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ClickableText } from '../../buttons/ClickableText'; +import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; +import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; +import { PostTagList } from '../tags/PostTagList'; +import { TruncateText } from '../../utilities'; +import { combinedClicks } from '../../../lib/click'; +import { useFeature } from '../../GrowthBookProvider'; +import { feature } from '../../../lib/featureManagement'; +import { SourceStrip } from '../reader/SourceStrip'; +import Link from '../../utilities/Link'; +import HoverCard from '../../cards/common/HoverCard'; +import SourceEntityCard from '../../cards/entity/SourceEntityCard'; +import { UserShortInfo } from '../../profile/UserShortInfo'; +import { ProfileImageSize } from '../../ProfilePicture'; +import type { UserShortProfile } from '../../../lib/user'; +import { FollowButton } from '../../contentPreference/FollowButton'; +import { ContentPreferenceType } from '../../../graphql/contentPreference'; +import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; +import { PostDiscoveryActionBar } from './PostDiscoveryActionBar'; +import { PostDiscussionPanel } from './PostDiscussionPanel'; +import { CollectionSources } from './CollectionSources'; + +const PostCodeSnippets = dynamic(() => + import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( + (mod) => ({ default: mod.PostCodeSnippets }), + ), +); + +export type FocusCardLeftVariant = 'lean' | 'rich'; + +interface PostFocusCardProps { + post: Post; + origin: PostOrigin; + leftVariant?: FocusCardLeftVariant; + /** When opened in the post modal, lets the sticky action bar close it. */ + onClose?: () => void; +} + +const ArticleLink = ({ + href, + onClick, + children, + ...props +}: ComponentProps<'a'> & { + href?: string; + onClick?: (event: React.MouseEvent) => void; +}) => { + const clickHandlers = onClick + ? combinedClicks(onClick) + : undefined; + return ( + + {children} + + ); +}; + +/** + * Video TL;DR capped to four lines. When the text overflows, a blue "Show + * more" link sits at the end of the last visible line (with a fade so it + * blends into the clamped text) and expands the summary to full length. + */ +const VideoSummary = ({ summary }: { summary: string }): ReactElement => { + const ref = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isClamped, setIsClamped] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) { + return undefined; + } + const measure = () => setIsClamped(el.scrollHeight - el.clientHeight > 1); + measure(); + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [summary]); + + return ( +
+

+ {summary} +

+ {isClamped && !isExpanded && ( + + + setIsExpanded(true)} + > + Show more + + + )} +
+ ); +}; + +export const PostFocusCard = ({ + post, + origin, + leftVariant, + onClose, +}: PostFocusCardProps): ReactElement => { + // A shared post (someone reposting a post into a squad or onto their profile) + // wraps an underlying post. Only true Share-type posts get the "Shared via" + // treatment — auto-written articles/freeform posts render their own source. + const isShared = post.type === PostType.Share && !!post.sharedPost; + const article = (isShared ? post.sharedPost : post) as Post; + // Shared into a squad → "Shared via {squad}"; shared to a profile → just + // "Shared post" (we don't repeat the author's name). + const sharedVia = + isShared && post.source?.type === SourceType.Squad + ? post.source + : undefined; + const isCollection = article.type === PostType.Collection; + // Posts authored by a user (shared, freeform, welcome) lead with that + // user, shown exactly like a comment author. Publication-sourced posts + // (article/video/collection) keep their source strip. + const author = + post.type === PostType.Share || + post.type === PostType.Freeform || + post.type === PostType.Welcome + ? post.author + : undefined; + const isVideoType = isVideoPost(article); + const { title } = useSmartTitle(article); + const { onCopyPostLink, onReadArticle } = usePostContent({ origin, post }); + const { onShowUpvoted } = useUpvoteQuery(); + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + const showCodeSnippets = useFeature(feature.showCodeSnippets); + const focusCommentRef = useRef<() => void>(() => {}); + const discussionRef = useRef(null); + const [isVideoPlaying, setIsVideoPlaying] = useState(false); + const readHref = getReadArticleHref(post); + const handleImageClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle(); + }; + const scrollToDiscussion = () => + discussionRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + const scrollToComment = () => { + scrollToDiscussion(); + focusCommentRef.current(); + }; + + // Rendered in the header on tablet+ (next to Follow) but moved below the + // title on mobile, where the header row is too tight to hold both. + const renderReadButton = (className: string): ReactElement | null => + readHref && !isInternalReadType(post) ? ( + + ) : null; + + return ( +
+
+
+
+ {author ? ( +
+ null} + className={{ + container: 'min-w-0 !p-0 hover:bg-transparent', + textWrapper: 'min-w-0', + }} + /> + +
+ ) : ( + article.source && ( + + ) + )} + {renderReadButton('ml-auto hidden shrink-0 tablet:flex')} +
+ +
+ {sharedVia && ( +

+ Shared via + + + + {sharedVia.image && ( + + )} + {sharedVia.name} + + + + } + > + + +

+ )} + {isShared && !sharedVia && ( +

Shared post

+ )} + {!isShared && isCollection && ( +

Collection

+ )} + + {renderReadButton('w-fit tablet:hidden')} +
+ + + + 0 && ( + + From{' '} + + {article.domain} + + + ) + } + isVideoType={isVideoType} + readTime={article.readTime} + /> + + {isVideoType && ( +
+ {isVideoPlaying ? ( + + ) : ( + + )} +
+ )} + + {article.contentHtml ? ( + <> + + + + ) : ( + article.summary && + (isVideoType ? ( + + ) : ( +

+ {article.summary} +

+ )) + )} + + + + onShowUpvoted(post.id, upvotes)} + onCommentsClick={scrollToComment} + /> + + {isCollection && } + + {showCodeSnippets && ( +
+ +
+ )} + + + +
+ { + focusCommentRef.current = fn; + }} + post={post} + origin={origin} + /> +
+
+
+
+ ); +}; diff --git a/packages/shared/src/components/post/reader/SourceStrip.tsx b/packages/shared/src/components/post/reader/SourceStrip.tsx index f91e4e5cc36..cbc4ceb57f3 100644 --- a/packages/shared/src/components/post/reader/SourceStrip.tsx +++ b/packages/shared/src/components/post/reader/SourceStrip.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { SourceTooltip } from '../../../graphql/sources'; import { FollowButton } from '../../contentPreference/FollowButton'; import { useContentPreferenceStatusQuery } from '../../../hooks/contentPreference/useContentPreferenceStatusQuery'; @@ -18,9 +19,17 @@ import SourceEntityCard from '../../cards/entity/SourceEntityCard'; type SourceStripProps = { source: SourceTooltip; + className?: string; + compact?: boolean; + followButtonVariant?: ButtonVariant; }; -export function SourceStrip({ source }: SourceStripProps): ReactElement | null { +export function SourceStrip({ + source, + className, + compact = false, + followButtonVariant = ButtonVariant.Secondary, +}: SourceStripProps): ReactElement | null { const sourceId = source?.id ?? ''; const sourceName = source?.name ?? ''; const { showActionBtn } = useShowFollowAction({ @@ -38,14 +47,25 @@ export function SourceStrip({ source }: SourceStripProps): ReactElement | null { const sourceHandle = source.handle ? `@${source.handle}` : null; return ( -
+
+ diff --git a/packages/shared/src/components/video/YoutubeVideo.tsx b/packages/shared/src/components/video/YoutubeVideo.tsx index b8ddb8e312c..e08708c6d2a 100644 --- a/packages/shared/src/components/video/YoutubeVideo.tsx +++ b/packages/shared/src/components/video/YoutubeVideo.tsx @@ -12,6 +12,7 @@ import { webappUrl } from '../../lib/constants'; interface YoutubeVideoProps extends HTMLAttributes { videoId: string; className?: string; + autoplay?: boolean; placeholderProps: Pick< YoutubeVideoWithoutConsentProps, 'post' | 'onWatchVideo' @@ -21,6 +22,7 @@ interface YoutubeVideoProps extends HTMLAttributes { const YoutubeVideo = ({ videoId, className, + autoplay = false, placeholderProps, ...props }: YoutubeVideoProps): ReactElement => { @@ -47,11 +49,15 @@ const YoutubeVideo = ({ ); } + // Cross-origin iframes block UNMUTED autoplay even with a parent click + // gesture, so YouTube would fall back to its play button (a second press). + // Muted autoplay is reliably permitted; YouTube shows its native unmute. + const autoplayParam = autoplay ? '?autoplay=1&mute=1' : ''; // Extension pages don't send Referer header, causing YouTube Error 153 // Use webapp as intermediate page which sends proper Referer const embedSrc = isExtension - ? `${webappUrl}embed/youtube/${videoId}` - : `https://www.youtube-nocookie.com/embed/${videoId}`; + ? `${webappUrl}embed/youtube/${videoId}${autoplayParam}` + : `https://www.youtube-nocookie.com/embed/${videoId}${autoplayParam}`; return ( diff --git a/packages/shared/src/hooks/post/usePostDiscoveryExperience.ts b/packages/shared/src/hooks/post/usePostDiscoveryExperience.ts new file mode 100644 index 00000000000..0c8cbb382c7 --- /dev/null +++ b/packages/shared/src/hooks/post/usePostDiscoveryExperience.ts @@ -0,0 +1,42 @@ +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { featurePostDiscoveryExperience } from '../../lib/featureManagement'; + +// Post types the Pinterest-style discovery layout knows how to render. Each is +// rendered fully in PostFocusCard: articles/videos show the TLDR, while +// collections and squad posts render their full markdown body. Specialized +// types (poll, brief, social, digest) keep their dedicated layouts. +export const postDiscoveryEligibleTypes: PostType[] = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Share, + PostType.Collection, + PostType.Freeform, + PostType.Welcome, +]; + +export const isPostDiscoveryEligible = ( + post?: Pick | null, +): boolean => !!post && postDiscoveryEligibleTypes.includes(post.type); + +interface UsePostDiscoveryExperience { + isEligible: boolean; + showDiscovery: boolean; +} + +/** + * Single source of truth for whether a post should render with the discovery + * layout, so the post page and the post modal stay in sync. + */ +export const usePostDiscoveryExperience = ( + post?: Post, +): UsePostDiscoveryExperience => { + const isEligible = isPostDiscoveryEligible(post); + const { value: isFlagOn } = useConditionalFeature({ + feature: featurePostDiscoveryExperience, + shouldEvaluate: isEligible, + }); + + return { isEligible, showDiscovery: isEligible && isFlagOn }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 9542f0e471d..34d7c7e6097 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -37,6 +37,10 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostPageFeed = new Feature('post_page_feed', false); +export const featurePostDiscoveryExperience = new Feature( + 'post_discovery_experience', + false, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index ca3205ed272..985d93e6ee3 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -310,6 +310,7 @@ export default { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', + 'composer-in': 'fade-slide-up 0.2s ease-out both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', 'reaction-burst': 'reaction-burst 720ms cubic-bezier(0.2, 0.7, 0.4, 1) forwards', diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index 87181d147d7..e68fcc136fe 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -84,6 +84,10 @@ jest.mock('next/router', () => ({ useRouter: jest.fn(), })); +// Toggled per-test to exercise the discovery (PostFocusCard) redesign; the flag +// defaults off so the classic layout renders unless a test flips this on. +let mockDiscoveryOn = false; + jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ __esModule: true, useConditionalFeature: (args: { @@ -96,6 +100,9 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ if (args?.feature?.id === 'post_page_feed') { return { value: true, isLoading: false }; } + if (args?.feature?.id === 'post_discovery_experience') { + return { value: mockDiscoveryOn, isLoading: false }; + } return { value: args?.feature?.defaultValue, isLoading: false }; }, })); @@ -103,6 +110,7 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ beforeEach(() => { nock.cleanAll(); jest.clearAllMocks(); + mockDiscoveryOn = false; jest.mocked(useRouter).mockImplementation( () => ({ @@ -1078,3 +1086,46 @@ describe('post page feed', () => { expect(result.current.isEligible).toBe(false); }); }); + +describe('post discovery experience', () => { + const mockRouterQuery = (query: Record) => { + jest.mocked(useRouter).mockImplementation( + () => + ({ + isFallback: false, + pathname: '/posts', + isReady: true, + query, + } as unknown as NextRouter), + ); + }; + + it('should render the focus card redesign when the flag is on', async () => { + mockDiscoveryOn = true; + renderPost(); + expect(await screen.findByTestId('post-focus-card')).toBeInTheDocument(); + expect(screen.queryByTestId('postContainer')).not.toBeInTheDocument(); + }); + + it('should keep the classic layout when the flag is off', async () => { + mockDiscoveryOn = false; + renderPost(); + expect(await screen.findByTestId('postContainer')).toBeInTheDocument(); + expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); + }); + + it('should force the redesign on with ?discovery=1 while the flag is off', async () => { + mockDiscoveryOn = false; + mockRouterQuery({ discovery: '1' }); + renderPost(); + expect(await screen.findByTestId('post-focus-card')).toBeInTheDocument(); + }); + + it('should force the classic layout with ?discovery=0 while the flag is on', async () => { + mockDiscoveryOn = true; + mockRouterQuery({ discovery: '0' }); + renderPost(); + expect(await screen.findByTestId('postContainer')).toBeInTheDocument(); + expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index c8e9b47c552..cebcf2f3f32 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -50,6 +50,10 @@ import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/Engage import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; import { PostPageFeed } from '@dailydotdev/shared/src/components/post/PostPageFeed'; import { usePostPageFeed } from '@dailydotdev/shared/src/hooks/post/usePostPageFeed'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { isPostDiscoveryEligible } from '@dailydotdev/shared/src/hooks/post/usePostDiscoveryExperience'; +import { featurePostDiscoveryExperience } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostFocusCard } from '@dailydotdev/shared/src/components/post/discovery/PostFocusCard'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -197,6 +201,21 @@ export const PostPage = ({ }, }); const { isEligible: showPostPageFeed } = usePostPageFeed(post); + const isDiscoveryEligible = isPostDiscoveryEligible(post); + const { value: isDiscoveryFlagOn } = useConditionalFeature({ + feature: featurePostDiscoveryExperience, + shouldEvaluate: isDiscoveryEligible, + }); + // `?discovery=1`/`0` lets us preview/force the redesign for review while the + // flag default stays off; absent, it follows the flag. + const forceDiscovery = + router.query.discovery === '1' || router.query.discovery === 'true'; + const forceClassic = + router.query.discovery === '0' || router.query.discovery === 'false'; + const showDiscovery = + isDiscoveryEligible && + !forceClassic && + (isDiscoveryFlagOn || forceDiscovery); const featureTheme = useFeatureTheme(); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', @@ -273,25 +292,33 @@ export const PostPage = ({ - - {shouldShowAuthBanner && isLaptop && } + {showDiscovery ? ( +
+ +
+ ) : ( + + )} + {!showDiscovery && shouldShowAuthBanner && isLaptop && ( + + )} {showPostPageFeed && } From 0fa1dd506ceba8d3171cf8746ec9a830d687c1b3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 00:03:50 +0300 Subject: [PATCH 02/11] =?UTF-8?q?fix(post):=20mobile=20action=20bar=20?= =?UTF-8?q?=E2=80=94=20drop=20sticky,=20fold=20utilities=20into=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mobile the focus-card action bar was always sticky and its two icon groups could exceed the viewport width, causing horizontal overflow. Make the bar static below tablet (sticky from tablet up) and progressively fold the lowest-priority utilities into the existing "…" menu as width shrinks — analytics below laptop, copy/share below tablet (both already live in the menu). Bookmark stays as the primary save action. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../post/discovery/PostDiscoveryActionBar.tsx | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 34543dd2f11..b573f2ecad4 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -144,7 +144,9 @@ export const PostDiscoveryActionBar = ({
- - } - onClick={() => onCopyLinkClick?.(post)} - /> - + {/* As the viewport narrows we fold the lowest-priority utilities into + the "…" menu (which already offers Share and Post analytics), + closest-to-the-menu first: analytics below laptop, then share/copy + below tablet. Bookmark stays — it is the primary save action and + is not in the menu. */} +
+ + } + onClick={() => onCopyLinkClick?.(post)} + /> + +
{post.clickbaitTitleDetected && ( )} {canSeeAnalytics && ( - - } - href={`${webappUrl}posts/${post.id}/analytics`} - /> - +
+ + } + href={`${webappUrl}posts/${post.id}/analytics`} + /> + +
)} Date: Thu, 11 Jun 2026 00:19:07 +0300 Subject: [PATCH 03/11] fix(post): make desktop action bar sticky + refine TLDR show-more - Root cause of broken desktop sticky: the v2 layout's floating-card wrapper used `overflow-hidden`, which establishes a scroll container and makes descendant `position: sticky` inert. Switch it to `overflow-clip` (same rounded-corner clipping, no scroll container) so the action bar sticks to the viewport. - Action bar sticky offset now adapts to the chrome: top-0 when the v2 rail owns the header (logged-in laptop), else clears the fixed 4rem header. - VideoSummary "Show more": render an ellipsis + link sized with typo-markdown so it aligns to the last clamped line (matching line height and fade height). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/components/MainLayout.tsx | 6 ++++- .../post/discovery/PostDiscoveryActionBar.tsx | 14 ++++++++---- .../post/discovery/PostFocusCard.tsx | 22 ++++++++++++------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 9cd66162d0b..60e4b7c1bf7 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -335,7 +335,11 @@ function MainLayoutComponent({
{ const { user, showLogin } = useAuthContext(); + const { isV2 } = useLayoutVariant(); const { toggleUpvote, toggleDownvote } = useVotePost(); const { toggleBookmark } = useBookmarkPost(); const { onShowPanel, onClose: onCloseBlockPanel } = useBlockPostPanel(post); @@ -92,10 +94,14 @@ export const PostDiscoveryActionBar = ({ const comments = post.numComments || 0; const awards = post.numAwards || 0; const canSeeAnalytics = canViewPostAnalytics({ user, post }); - // In the modal there is no app header, so pin to the very top; on the post - // page the bar must sit below the fixed laptop header (4rem). `onClose` is - // only provided by the post modal, so it doubles as the surface flag. - const stickyTopClassName = onClose ? 'top-0' : 'top-0 laptop:top-16'; + // Sticky offset depends on the top chrome. The modal has no app header (pin + // to the very top). On the post page, the v2 rail layout hides the global + // header on laptop for logged-in users, so the bar sticks to the very top + // like the feed nav; the legacy/logged-out layout keeps a fixed 4rem header + // the bar must clear. `onClose` is only provided by the modal. + const railOwnsHeader = isV2 && !!user; + const stickyTopClassName = + onClose || railOwnsHeader ? 'top-0' : 'top-0 laptop:top-16'; const onToggleUpvote = async () => { if (post?.userState?.vote === UserVote.None) { diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index dc6630a717f..b4ae2c99f9f 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -26,7 +26,6 @@ import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { ClickableText } from '../../buttons/ClickableText'; import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; @@ -128,18 +127,25 @@ const VideoSummary = ({ summary }: { summary: string }): ReactElement => { {summary}

{isClamped && !isExpanded && ( - + // Sits on the last clamped line: typo-markdown matches the paragraph's + // font size and line height, so the button (and its background/fade) + // are exactly one line tall and align to the text it continues. We + // render an ellipsis before the link so the cut-off reads naturally. + )}
); From 6b2ba87c40aaa1079dd331222e759f4e32d4d83e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 00:31:05 +0300 Subject: [PATCH 04/11] style(post): truncate video TLDR with an inline ellipsis, not a fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fade/shadow overlay on the clamped video TL;DR with a real word-boundary truncation: measure the fitting prefix with an off-screen clone and append an inline "… Show more" in the same font/size/weight as the body, recolouring only the link. No gradient. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../post/discovery/PostFocusCard.tsx | 123 ++++++++++++------ 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index b4ae2c99f9f..9e641618364 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -89,22 +89,74 @@ const ArticleLink = ({ ); }; +const SHOW_MORE_SUFFIX = '… Show more'; + /** - * Video TL;DR capped to four lines. When the text overflows, a blue "Show - * more" link sits at the end of the last visible line (with a fade so it - * blends into the clamped text) and expands the summary to full length. + * Video TL;DR capped to four lines. When the text overflows we truncate it at a + * word boundary and append an inline "… Show more" in the same font as the body + * (only the link is recoloured) — a real ellipsis cut rather than a fade + * overlay. The fitting prefix is measured with an off-screen clone so the + * suffix always lands on the last visible line. */ const VideoSummary = ({ summary }: { summary: string }): ReactElement => { const ref = useRef(null); const [isExpanded, setIsExpanded] = useState(false); - const [isClamped, setIsClamped] = useState(false); + // `null` => not measured yet or text fits; a string => the truncated prefix. + const [truncated, setTruncated] = useState(null); useEffect(() => { const el = ref.current; - if (!el) { + if (!el || isExpanded) { + setTruncated(null); return undefined; } - const measure = () => setIsClamped(el.scrollHeight - el.clientHeight > 1); + + const measure = () => { + const clone = el.cloneNode(false) as HTMLElement; + clone.classList.remove('line-clamp-4'); + Object.assign(clone.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + width: `${el.clientWidth}px`, + height: 'auto', + maxHeight: 'none', + }); + el.parentElement?.appendChild(clone); + + clone.textContent = 'Mg'; + const maxHeight = clone.scrollHeight * 4 + 1; + + clone.textContent = summary; + if (clone.scrollHeight <= maxHeight) { + clone.remove(); + setTruncated(null); + return; + } + + let lo = 0; + let hi = summary.length; + let best = 0; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + clone.textContent = `${summary + .slice(0, mid) + .trimEnd()}${SHOW_MORE_SUFFIX}`; + if (clone.scrollHeight <= maxHeight) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + clone.remove(); + + // Snap back to the previous word boundary so we never cut mid-word. + const prefix = summary.slice(0, best).trimEnd(); + const lastSpace = prefix.lastIndexOf(' '); + setTruncated(lastSpace > 0 ? prefix.slice(0, lastSpace) : prefix); + }; + measure(); if (typeof ResizeObserver === 'undefined') { return undefined; @@ -112,42 +164,37 @@ const VideoSummary = ({ summary }: { summary: string }): ReactElement => { const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); - }, [summary]); + }, [summary, isExpanded]); + + const isTruncated = !isExpanded && truncated !== null; return ( -
-

- {summary} -

- {isClamped && !isExpanded && ( - // Sits on the last clamped line: typo-markdown matches the paragraph's - // font size and line height, so the button (and its background/fade) - // are exactly one line tall and align to the text it continues. We - // render an ellipsis before the link so the cut-off reads naturally. - + + + ) : ( + summary )} -
+

); }; From ee2ac6b0f41459f14f22bd8bf0a8e9187e0fa0ac Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 00:43:40 +0300 Subject: [PATCH 05/11] style(post): keep title+image side-by-side on mobile, smaller title Render the focus-card title and cover image in a row on every breakpoint (matching desktop) instead of stacking on mobile; the image stays square on mobile and tablet (wide only on laptop). Drop the title one step at each breakpoint (title2->title3 mobile, large-title->title1 tablet+). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/src/components/post/discovery/PostFocusCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 9e641618364..ed3dc3e2243 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -357,9 +357,9 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} -
+

{title} From 2506bad1b03bf382b2fc171021a23acfd77ff1eb Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 00:50:06 +0300 Subject: [PATCH 06/11] feat(post): dynamically overflow action-bar utilities into the menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed breakpoint hiding of the analytics and copy/share icons with a measured overflow behavior: a ResizeObserver shows them inline whenever the action bar has room and folds them into the "…" menu (which already lists both) when it would overflow, re-evaluating on page/modal resize. Folds analytics first, then copy/share; bookmark stays inline. The breakpoint classes remain only as the pre-measurement SSR fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../post/discovery/PostDiscoveryActionBar.tsx | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 9eccbc7889b..d45f43ef2d0 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -73,6 +73,9 @@ export const PostDiscoveryActionBar = ({ // Detect when the sticky bar is pinned to the top so the X close button // (modal only) appears just for the stuck state. const sentinelRef = useRef(null); + const barRef = useRef(null); + const copyLinkRef = useRef(null); + const analyticsRef = useRef(null); const [isStuck, setIsStuck] = useState(false); useEffect(() => { const el = sentinelRef.current; @@ -103,6 +106,52 @@ export const PostDiscoveryActionBar = ({ const stickyTopClassName = onClose || railOwnsHeader ? 'top-0' : 'top-0 laptop:top-16'; + // Dynamically fold the lowest-priority utilities into the "…" menu (which + // already lists Share and Post analytics) whenever the bar would overflow, + // and bring them back inline when there is room again. Measured against the + // real available width — not breakpoints — so it reacts to page/modal + // resizing. Priority (first to fold): analytics, then copy/share. + useEffect(() => { + const bar = barRef.current; + if (!bar) { + return undefined; + } + const fit = () => { + const copyLink = copyLinkRef.current; + const analytics = analyticsRef.current; + // Show both first (inline display overrides the SSR fallback classes), + // then hide in priority order until the row stops overflowing. + if (copyLink) { + copyLink.style.display = 'flex'; + } + if (analytics) { + analytics.style.display = 'flex'; + } + const overflows = () => bar.scrollWidth > bar.clientWidth; + if (analytics && overflows()) { + analytics.style.display = 'none'; + } + 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(); + }, [ + canSeeAnalytics, + upvotes, + comments, + awards, + canAward, + post.clickbaitTitleDetected, + post.bookmarked, + ]); + const onToggleUpvote = async () => { if (post?.userState?.vote === UserVote.None) { onCloseBlockPanel(true); @@ -149,6 +198,7 @@ export const PostDiscoveryActionBar = ({ <>
- {/* As the viewport narrows we fold the lowest-priority utilities into - the "…" menu (which already offers Share and Post analytics), - closest-to-the-menu first: analytics below laptop, then share/copy - below tablet. Bookmark stays — it is the primary save action and - is not in the menu. */} -
+ {/* Bookmark stays — it is the primary save action and is not in the + menu. Copy/share and analytics fold into the "…" menu when space + is tight (see the overflow effect); the `hidden tablet:flex` / + `hidden laptop:flex` classes are only the pre-measurement (SSR) + fallback — the effect overrides display once it measures. */} +
)} {canSeeAnalytics && ( -
+
Date: Thu, 11 Jun 2026 00:54:56 +0300 Subject: [PATCH 07/11] style(post): two-tier title/image layout at the 656px breakpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From tablet (656px) up: desktop layout — wide original-ratio cover image and the read button on the top row. Below 656px: a smaller square thumbnail beside the title with the read button directly under the title at a tight gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../post/discovery/PostFocusCard.tsx | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index ed3dc3e2243..7dbcc02760b 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -357,36 +357,41 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} -
- {renderReadButton('w-fit tablet:hidden')}
Date: Thu, 11 Jun 2026 01:17:08 +0300 Subject: [PATCH 08/11] style(post): button under title in same column, top-align image, clamp title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the below-title read button into the same column as the title (tight gap) so it sits directly under the title instead of below the whole title+image row — no large gap when the image is taller than the title. - Title and image are top-aligned columns (items-start), so the image stays pinned to the top as the title/button column grows. - Clamp the title to 3 lines with an ellipsis so it never overruns. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../post/discovery/PostFocusCard.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 7dbcc02760b..777ea163c72 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -357,40 +357,41 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} - {/* Below the 656px (tablet) breakpoint we keep the read button - directly under the title with a tight gap; from tablet up the - read button moves to the top row and this one is hidden. */} -
-
+ {/* Title and image are top-aligned columns. The below-title read + button lives in the SAME column as the title (tight gap) so it + sits right under it regardless of the image height; from tablet + (656px) up the button moves to the top row and this one hides. */} +
From 8ebeb21f212680039348423088cb1d79fd006a28 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 01:31:18 +0300 Subject: [PATCH 09/11] =?UTF-8?q?fix(post):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20anon=20shield,=20share=20notif,=20author=20flow,=20TLDR=20ed?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostClickbaitShield iconOnly: prompt login for anonymous users instead of throwing when they tap the shield on desktop. - SharePostModal: always render the squad EnableNotification prompt, not only in the classic (non-discovery) branch. - Post page: keep the classic layout for entry-specific flows the focus card doesn't render (?author onboarding, ?squad back-to-squad), even with the flag on; add a regression test. - VideoSummary: never render a bare "… Show more" with no preview (fall back to the CSS line-clamp). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/modals/SharePostModal.tsx | 56 +++++++++---------- .../post/common/PostClickbaitShield.tsx | 8 +-- .../post/discovery/PostFocusCard.tsx | 5 +- packages/webapp/__tests__/PostPage.tsx | 8 +++ packages/webapp/pages/posts/[id]/index.tsx | 4 ++ 5 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/shared/src/components/modals/SharePostModal.tsx b/packages/shared/src/components/modals/SharePostModal.tsx index a05dc22d650..d08083b4fe4 100644 --- a/packages/shared/src/components/modals/SharePostModal.tsx +++ b/packages/shared/src/components/modals/SharePostModal.tsx @@ -50,6 +50,16 @@ export default function PostModal({ onPreviousPost={onPreviousPost} onNextPost={onNextPost} > + {/* The squad notification prompt is shown for share posts regardless of + which layout renders below it. */} + {showDiscovery ? ( onRequestClose?.(undefined as never)} /> ) : ( - <> - - - + )} ); diff --git a/packages/shared/src/components/post/common/PostClickbaitShield.tsx b/packages/shared/src/components/post/common/PostClickbaitShield.tsx index e4e2b6da74d..ab848937ef0 100644 --- a/packages/shared/src/components/post/common/PostClickbaitShield.tsx +++ b/packages/shared/src/components/post/common/PostClickbaitShield.tsx @@ -19,6 +19,7 @@ import type { Post } from '../../../graphql/posts'; import { FeedSettingsMenu } from '../../feeds/FeedSettings/types'; import { webappUrl } from '../../../lib/constants'; import { useAuthContext } from '../../../contexts/AuthContext'; +import { AuthTriggers } from '../../../lib/auth'; import { Tooltip } from '../../tooltip/Tooltip'; import { Typography, TypographyType } from '../../typography/Typography'; import { PostUpgradeToPlus } from '../../plus/PostUpgradeToPlus'; @@ -37,7 +38,7 @@ export const PostClickbaitShield = ({ useSmartTitle(post); const isMobile = useViewSize(ViewSize.MobileL); const router = useRouter(); - const { user } = useAuthContext(); + const { user, showLogin } = useAuthContext(); const { hasUsedFreeTrial, triesLeft } = useClickbaitTries(); if (iconOnly) { @@ -54,9 +55,8 @@ export const PostClickbaitShield = ({ } if (!user) { - throw new Error( - 'PostClickbaitShield requires an authenticated user to edit feed settings', - ); + showLogin({ trigger: AuthTriggers.Filter }); + return; } router.push( diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 777ea163c72..83e35da3bac 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -154,7 +154,10 @@ const VideoSummary = ({ summary }: { summary: string }): ReactElement => { // Snap back to the previous word boundary so we never cut mid-word. const prefix = summary.slice(0, best).trimEnd(); const lastSpace = prefix.lastIndexOf(' '); - setTruncated(lastSpace > 0 ? prefix.slice(0, lastSpace) : prefix); + const snapped = lastSpace > 0 ? prefix.slice(0, lastSpace) : prefix; + // Never render a bare "… Show more" with no preview; fall back to the + // CSS line-clamp instead. + setTruncated(snapped || null); }; measure(); diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index e68fcc136fe..0c0180872bf 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -1128,4 +1128,12 @@ describe('post discovery experience', () => { expect(await screen.findByTestId('postContainer')).toBeInTheDocument(); expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); }); + + it('should keep the classic layout for author onboarding even when the flag is on', async () => { + mockDiscoveryOn = true; + mockRouterQuery({ author: 'true' }); + renderPost(); + expect(await screen.findByTestId('postContainer')).toBeInTheDocument(); + expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); + }); }); diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index cebcf2f3f32..8cb08f33381 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -212,9 +212,13 @@ export const PostPage = ({ router.query.discovery === '1' || router.query.discovery === 'true'; const forceClassic = router.query.discovery === '0' || router.query.discovery === 'false'; + // Entry-specific flows the focus card doesn't render (author onboarding via + // `?author`, back-to-squad via `?squad`) stay on the classic layout. + const requiresClassicLayout = !!router.query?.author || !!router.query?.squad; const showDiscovery = isDiscoveryEligible && !forceClassic && + !requiresClassicLayout && (isDiscoveryFlagOn || forceDiscovery); const featureTheme = useFeatureTheme(); const containerClass = classNames( From c2ee7e4e9797fc50e9febb333b400a4f6942575a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 01:42:44 +0300 Subject: [PATCH 10/11] style(post): nudge title/read-button gap up to gap-2.5 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/components/post/discovery/PostFocusCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 83e35da3bac..29b617295a9 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -365,7 +365,7 @@ export const PostFocusCard = ({ sits right under it regardless of the image height; from tablet (656px) up the button moves to the top row and this one hides. */}
-
+

Date: Thu, 11 Jun 2026 01:54:04 +0300 Subject: [PATCH 11/11] style(post): show the full title on the post page, clamp only in the modal On the post page the reader intends to read the article, so the title is always shown in full and the read button flows below however long it is; only the post modal (a feed preview) clamps the title to 3 lines. Also widen the title-to-button gap (gap-2.5 -> gap-4). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/post/discovery/PostFocusCard.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 29b617295a9..123b938c720 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -365,9 +365,15 @@ export const PostFocusCard = ({ sits right under it regardless of the image height; from tablet (656px) up the button moves to the top row and this one hides. */}
-
+

{title}