From 97626230af7c0c6feef456482255846ec9e460bd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 11 Jun 2026 17:59:40 +0300 Subject: [PATCH 1/2] feat(post): redesigned post page & modal reader (PostFocusCard) Standalone, off main: a focused single-column reader card (source/author, title + cover image, sticky action bar, TL;DR or full body, and the live discussion through the end of the comments), wired into the article/collection/ share post modals and the post page behind the `post_redesign` flag (default off; `?redesign=1`/`0` previews it). The classic layout stays for ineligible types, author onboarding (`?author`) and back-to-squad (`?squad`). Includes additive, backward-compatible props on shared primitives (SourceStrip compact, PostSidebarAdWidget inline, PostClickbaitShield iconOnly, PostComments removeTopSpacing, PostUpvotesCommentsCount onCommentsClick, YoutubeVideo autoplay), a composer-in animation, and MainLayout overflow-clip so the sticky action bar works on laptop. No discovery feed. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/components/MainLayout.tsx | 6 +- .../components/modals/ArticlePostModal.tsx | 49 +- .../components/modals/CollectionPostModal.tsx | 49 +- .../src/components/modals/SharePostModal.tsx | 49 +- .../src/components/post/PostComments.tsx | 14 +- .../components/post/PostSidebarAdWidget.tsx | 94 ++++ .../post/PostUpvotesCommentsCount.tsx | 17 +- .../post/common/PostClickbaitShield.tsx | 69 ++- .../post/focus/CollectionSources.tsx | 85 +++ .../post/focus/DiscussionMetaBar.tsx | 106 ++++ .../post/focus/DiscussionShareRow.tsx | 181 ++++++ .../post/focus/FocusCardActionBar.tsx | 323 +++++++++++ .../post/focus/PostDiscussionPanel.tsx | 231 ++++++++ .../components/post/focus/PostFocusCard.tsx | 531 ++++++++++++++++++ .../components/post/reader/SourceStrip.tsx | 41 +- .../src/components/video/YoutubeVideo.tsx | 10 +- .../shared/src/hooks/post/usePostRedesign.ts | 40 ++ packages/shared/src/lib/featureManagement.ts | 1 + packages/shared/tailwind.config.ts | 1 + packages/webapp/__tests__/PostPage.tsx | 59 ++ packages/webapp/pages/posts/[id]/index.tsx | 69 ++- 21 files changed, 1933 insertions(+), 92 deletions(-) create mode 100644 packages/shared/src/components/post/focus/CollectionSources.tsx create mode 100644 packages/shared/src/components/post/focus/DiscussionMetaBar.tsx create mode 100644 packages/shared/src/components/post/focus/DiscussionShareRow.tsx create mode 100644 packages/shared/src/components/post/focus/FocusCardActionBar.tsx create mode 100644 packages/shared/src/components/post/focus/PostDiscussionPanel.tsx create mode 100644 packages/shared/src/components/post/focus/PostFocusCard.tsx create mode 100644 packages/shared/src/hooks/post/usePostRedesign.ts 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({
- + {showRedesign ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + + )} ); } diff --git a/packages/shared/src/components/modals/CollectionPostModal.tsx b/packages/shared/src/components/modals/CollectionPostModal.tsx index 4407085abc6..9cf86c30cf7 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 { usePostRedesign } from '../../hooks/post/usePostRedesign'; +import { PostFocusCard } from '../post/focus/PostFocusCard'; interface CollectionPostModalProps extends ModalProps, @@ -31,12 +33,15 @@ export default function CollectionPostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { showRedesign } = usePostRedesign(post); return ( - + {showRedesign ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + + )} ); } diff --git a/packages/shared/src/components/modals/SharePostModal.tsx b/packages/shared/src/components/modals/SharePostModal.tsx index 96d4a6a1547..e604fdfb357 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 { usePostRedesign } from '../../hooks/post/usePostRedesign'; +import { PostFocusCard } from '../post/focus/PostFocusCard'; interface PostModalProps extends ModalProps, PassedPostNavigationProps { id: string; @@ -31,13 +33,15 @@ export default function PostModal({ isDisplayed: props.isOpen, offset: 0, }); + const { showRedesign } = usePostRedesign(post); return ( + {/* The squad notification prompt is shown for share posts regardless of + which layout renders below it. */} - + {showRedesign ? ( + onRequestClose?.(undefined as never)} + /> + ) : ( + + )} ); } diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index dfa0691187a..a625345ed40 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 redesign 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 ( + + ); + } + 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..ab848937ef0 100644 --- a/packages/shared/src/components/post/common/PostClickbaitShield.tsx +++ b/packages/shared/src/components/post/common/PostClickbaitShield.tsx @@ -12,27 +12,92 @@ 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'; 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'; 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 } = useSmartTitle(post); const isMobile = useViewSize(ViewSize.MobileL); const router = useRouter(); - const { user } = useAuthContext(); + const { user, showLogin } = 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) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + + 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 ( + +
+ {rightSlot && ( +
+ {rightSlot} +
+ )} + + ); +}; diff --git a/packages/shared/src/components/post/focus/DiscussionShareRow.tsx b/packages/shared/src/components/post/focus/DiscussionShareRow.tsx new file mode 100644 index 00000000000..51058cae65e --- /dev/null +++ b/packages/shared/src/components/post/focus/DiscussionShareRow.tsx @@ -0,0 +1,181 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import type { Squad } from '../../../graphql/sources'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { CopyIcon, ShareIcon, TwitterIcon, WhatsappIcon } from '../../icons'; +import { useCopyPostLink } from '../../../hooks/useCopyPostLink'; +import { useGetShortUrl } from '../../../hooks'; +import { getShareLink, ShareProvider } from '../../../lib/share'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { postLogEvent } from '../../../lib/feed'; +import { LogEvent, Origin } from '../../../lib/log'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../modals/common/types'; +import { ReferralCampaignKey } from '../../../lib'; +import { getShareableSquads } from '../../squads/SquadsToShare'; +import SourceProfilePicture from '../../profile/SourceProfilePicture'; +import { ProfileImageSize } from '../../ProfilePicture'; + +interface DiscussionShareRowProps { + post: Post; + className?: string; + /** + * Surfaces the user's squads as inline avatar buttons for quick sharing. + * The trailing "more" action still opens the full share modal with every + * squad and option. + */ + withSquads?: boolean; +} + +const maxInlineSquads = 4; +const mobileInlineSquads = 2; + +/** + * Compact share row for the discussion panel. Surfaces the most-used quick + * actions (copy, X, WhatsApp) inline and defers the long tail (Facebook, + * squads, native share) to the full Share modal behind a single "more" action. + */ +export const DiscussionShareRow = ({ + post, + className, + withSquads = false, +}: DiscussionShareRowProps): ReactElement => { + const href = post.commentsPermalink; + const cid = ReferralCampaignKey.SharePost; + const { getShortUrl } = useGetShortUrl(); + const [copying, copyLink] = useCopyPostLink(); + const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); + const { squads } = useAuthContext(); + const inlineSquads = withSquads + ? getShareableSquads(squads).slice(0, maxInlineSquads) + : []; + + const onShareToSquad = (squad: Squad) => { + logEvent(postLogEvent(LogEvent.StartShareToSquad, post)); + openModal({ + type: LazyModal.CreateSharedPost, + props: { + squad, + preview: post, + onSharedSuccessfully: () => + logEvent(postLogEvent(LogEvent.ShareToSquad, post)), + }, + }); + }; + + const logShareEvent = (provider: ShareProvider) => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.ShareBar }, + }), + ); + + const onShare = async (provider: ShareProvider) => { + logShareEvent(provider); + const shortLink = await getShortUrl(href, cid); + const shareLink = getShareLink({ + provider, + link: shortLink, + text: post?.title, + }); + globalThis.window?.open(shareLink, '_blank'); + }; + + const onCopy = async () => { + const shortLink = await getShortUrl(href, cid); + copyLink({ link: shortLink }); + logShareEvent(ShareProvider.CopyLink); + }; + + const onMore = () => + openModal({ + type: LazyModal.Share, + props: { post, origin: Origin.ShareBar }, + }); + + return ( +
+ Share this post +
+ +
+
+ ); +}; diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx new file mode 100644 index 00000000000..3d6fd7b5e1d --- /dev/null +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -0,0 +1,323 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { UserVote } from '../../../graphql/posts'; +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 { + AnalyticsIcon, + DiscussIcon as CommentIcon, + DownvoteIcon, + LinkIcon, + MedalBadgeIcon, +} from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import type { LoggedUser } from '../../../lib/user'; +import { canViewPostAnalytics } from '../../../lib/user'; +import { webappUrl } from '../../../lib/constants'; +import { PostMenuOptions } from '../PostMenuOptions'; +import { PostClickbaitShield } from '../common/PostClickbaitShield'; + +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. + */ +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); + const { openModal } = useLazyModal(); + const canAward = useCanAwardUser({ + sendingUser: user, + receivingUser: post.author as LoggedUser | undefined, + }); + + // 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; + if (!el || typeof IntersectionObserver === 'undefined') { + return undefined; + } + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { 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; + const upvotes = post.numUpvotes || 0; + const comments = post.numComments || 0; + const awards = post.numAwards || 0; + const canSeeAnalytics = canViewPostAnalytics({ user, post }); + // 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'; + + // 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); + } + await toggleUpvote({ payload: post, origin }); + }; + + const onToggleDownvote = async () => { + if (post.userState?.vote !== UserVote.Down) { + onShowPanel(); + } else { + onCloseBlockPanel(true); + } + await toggleDownvote({ payload: post, origin }); + }; + + const onToggleBookmark = async () => { + await toggleBookmark({ post, origin }); + }; + + const onGiveAward = () => { + if (!user) { + showLogin({ trigger: AuthTriggers.GiveAward }); + return; + } + if (!post.author || isAwarded) { + return; + } + openModal({ + type: LazyModal.GiveAward, + props: { + type: 'POST', + entity: { + id: post.id, + receiver: post.author, + numAwards: post.numAwards, + }, + post, + }, + }); + }; + + return ( + <> +
+
+
+ + } + iconPressed={} + count={upvotes} + pressed={isUpvoteActive} + onClick={onToggleUpvote} + /> + + + } + iconPressed={} + pressed={isDownvoteActive} + onClick={onToggleDownvote} + /> + + + } + iconPressed={} + count={comments} + pressed={post.commented} + onClick={onComment} + /> + + {canAward && ( + + } + iconPressed={} + count={awards} + pressed={isAwarded} + onClick={onGiveAward} + /> + + )} +
+ +
+ + {/* 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. */} +
+ + } + onClick={() => onCopyLinkClick?.(post)} + /> + +
+ {post.clickbaitTitleDetected && ( + + )} + {canSeeAnalytics && ( +
+ + } + href={`${webappUrl}posts/${post.id}/analytics`} + /> + +
+ )} + + {isStuck && onClose && ( + onClose()} /> + )} +
+
+ + ); +}; diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx new file mode 100644 index 00000000000..2b29250d7c9 --- /dev/null +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -0,0 +1,231 @@ +import dynamic from 'next/dynamic'; +import type { LegacyRef, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { useShareComment } from '../../../hooks/useShareComment'; +import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; +import { Origin } from '../../../lib/log'; +import { PostComments } from '../PostComments'; +import type { + NewCommentRef, + NewCommentTriggerRenderProps, +} from '../NewComment'; +import { NewComment } from '../NewComment'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { + getProfilePictureClasses, + ProfileImageSize, + ProfilePicture, +} from '../../ProfilePicture'; +import { Image } from '../../image/Image'; +import { fallbackImages } from '../../../lib/config'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../../buttons/Button'; +import { TimeSortIcon } from '../../icons/Sort/Time'; +import { SortCommentsBy } from '../../../graphql/comments'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { DiscussionMetaBar } from './DiscussionMetaBar'; +import { DiscussionShareRow } from './DiscussionShareRow'; + +const CommentInputOrModal = dynamic( + () => + import( + /* webpackChunkName: "commentInputOrModal" */ '../../comments/CommentInputOrModal' + ), +); + +export interface PostDiscussionPanelProps { + post: Post; + origin?: Origin; + className?: string; + /** + * Renders the post stats + comment sort strip inside the panel. Disable it + * when a parent (e.g. the redesign focus card) surfaces the strip elsewhere. + */ + showMetaBar?: boolean; + /** + * Renders a small comment-sort toggle ("Newest first"/"Oldest first") at the + * top of the comment list. Used by the redesign focus card, which moves the + * post stats/actions out of this panel. + */ + showSortHeader?: boolean; + /** + * Lets a parent (e.g. a floating "comment" action) focus the composer. + */ + onRegisterFocusComment?: (fn: () => void) => void; + /** + * The element the comment edit/reply modals portal into. Defaults to the + * panel root so modals stay scoped to this surface. + */ + modalParentSelector?: () => HTMLElement; +} + +const noopFocus = (): void => {}; + +/** + * The discussion surface (comments, composer, share) shared by the reader's + * EngagementRail and the post redesign focus card. Extracted so both surfaces + * stay in sync instead of duplicating the comment stack. + */ +export const PostDiscussionPanel = ({ + post, + origin = Origin.ArticlePage, + className, + showMetaBar = true, + showSortHeader = false, + onRegisterFocusComment, + modalParentSelector, +}: PostDiscussionPanelProps): ReactElement => { + const { sortCommentsBy: sortBy, updateSortCommentsBy: setSortBy } = + useSettingsContext(); + const isNewestFirst = sortBy === SortCommentsBy.NewestFirst; + const commentRef = useRef(null); + const rootRef = useRef(null); + const [isComposerOpen, setIsComposerOpen] = useState(false); + const { onShowUpvoted } = useUpvoteQuery(); + const { openShareComment } = useShareComment(origin); + + useEffect(() => { + if (!onRegisterFocusComment) { + return undefined; + } + + const run = (): void => { + commentRef.current?.onShowInput(Origin.PostCommentButton); + }; + onRegisterFocusComment(run); + + return () => { + onRegisterFocusComment(noopFocus); + }; + }, [onRegisterFocusComment, post.id]); + + const resolveModalParent = (): HTMLElement => { + if (modalParentSelector) { + return modalParentSelector(); + } + + return rootRef.current ?? document.body; + }; + + const renderComposerTrigger = ({ + user: triggerUser, + onCommentClick, + }: NewCommentTriggerRenderProps): ReactElement => ( + + ); + + 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/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx new file mode 100644 index 00000000000..1faffad4ff7 --- /dev/null +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -0,0 +1,531 @@ +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 { 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 { FocusCardActionBar } from './FocusCardActionBar'; +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} + + ); +}; + +const SHOW_MORE_SUFFIX = '… Show more'; + +/** + * 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); + // `null` => not measured yet or text fits; a string => the truncated prefix. + const [truncated, setTruncated] = useState(null); + + useEffect(() => { + const el = ref.current; + if (!el || isExpanded) { + setTruncated(null); + return undefined; + } + + 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(' '); + 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(); + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [summary, isExpanded]); + + const isTruncated = !isExpanded && truncated !== null; + + return ( +

+ {isTruncated ? ( + <> + {truncated} + {'… '} + + + ) : ( + summary + )} +

+ ); +}; + +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

+ )} + {/* 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. */} + +
+ + + + 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/usePostRedesign.ts b/packages/shared/src/hooks/post/usePostRedesign.ts new file mode 100644 index 00000000000..240c3f73a8a --- /dev/null +++ b/packages/shared/src/hooks/post/usePostRedesign.ts @@ -0,0 +1,40 @@ +import type { Post } from '../../graphql/posts'; +import { PostType } from '../../graphql/posts'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { featurePostRedesign } from '../../lib/featureManagement'; + +// Post types the Pinterest-style redesign 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 postRedesignEligibleTypes: PostType[] = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Share, + PostType.Collection, + PostType.Freeform, + PostType.Welcome, +]; + +export const isPostRedesignEligible = ( + post?: Pick | null, +): boolean => !!post && postRedesignEligibleTypes.includes(post.type); + +interface UsePostRedesign { + isEligible: boolean; + showRedesign: boolean; +} + +/** + * Single source of truth for whether a post should render with the redesign + * layout, so the post page and the post modal stay in sync. + */ +export const usePostRedesign = (post?: Post): UsePostRedesign => { + const isEligible = isPostRedesignEligible(post); + const { value: isFlagOn } = useConditionalFeature({ + feature: featurePostRedesign, + shouldEvaluate: isEligible, + }); + + return { isEligible, showRedesign: isEligible && isFlagOn }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 37635ffb92d..5e5361928e4 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -37,6 +37,7 @@ export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, ); +export const featurePostRedesign = new Feature('post_redesign', 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 742cd9086b6..f644e689a4f 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -319,6 +319,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 c53565aedf3..855b37fae0b 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -82,6 +82,10 @@ jest.mock('next/router', () => ({ useRouter: jest.fn(), })); +// Toggled per-test to exercise the redesigned post page (PostFocusCard); the +// flag defaults off so the classic layout renders unless a test flips it on. +let mockRedesignOn = false; + jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ __esModule: true, useConditionalFeature: (args: { @@ -90,6 +94,9 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ if (args?.feature?.id === 'reader_modal') { return { value: false, isLoading: false }; } + if (args?.feature?.id === 'post_redesign') { + return { value: mockRedesignOn, isLoading: false }; + } return { value: args?.feature?.defaultValue, isLoading: false }; }, })); @@ -97,6 +104,7 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ beforeEach(() => { nock.cleanAll(); jest.clearAllMocks(); + mockRedesignOn = false; jest.mocked(useRouter).mockImplementation( () => ({ @@ -1028,3 +1036,54 @@ describe('article', () => { ); }); }); + +describe('post redesign', () => { + 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 () => { + mockRedesignOn = 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 () => { + mockRedesignOn = false; + renderPost(); + expect(await screen.findByTestId('postContainer')).toBeInTheDocument(); + expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); + }); + + it('should force the redesign on with ?redesign=1 while the flag is off', async () => { + mockRedesignOn = false; + mockRouterQuery({ redesign: '1' }); + renderPost(); + expect(await screen.findByTestId('post-focus-card')).toBeInTheDocument(); + }); + + it('should force the classic layout with ?redesign=0 while the flag is on', async () => { + mockRedesignOn = true; + mockRouterQuery({ redesign: '0' }); + renderPost(); + 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 () => { + mockRedesignOn = 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 fce81b9431d..4b965507f79 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -48,6 +48,10 @@ import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { isPostRedesignEligible } from '@dailydotdev/shared/src/hooks/post/usePostRedesign'; +import { featurePostRedesign } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostFocusCard } from '@dailydotdev/shared/src/components/post/focus/PostFocusCard'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -194,6 +198,25 @@ export const PostPage = ({ retry: false, }, }); + const isRedesignEligible = isPostRedesignEligible(post); + const { value: isRedesignFlagOn } = useConditionalFeature({ + feature: featurePostRedesign, + shouldEvaluate: isRedesignEligible, + }); + // `?redesign=1`/`0` lets us preview/force the redesign for review while the + // flag default stays off; absent, it follows the flag. + const forceRedesign = + router.query.redesign === '1' || router.query.redesign === 'true'; + const forceClassic = + router.query.redesign === '0' || router.query.redesign === '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 showRedesign = + isRedesignEligible && + !forceClassic && + !requiresClassicLayout && + (isRedesignFlagOn || forceRedesign); 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', @@ -270,25 +293,33 @@ export const PostPage = ({ - - {shouldShowAuthBanner && isLaptop && } + {showRedesign ? ( +
+ +
+ ) : ( + + )} + {!showRedesign && shouldShowAuthBanner && isLaptop && ( + + )} From 73d1ad7512f216be895d003be117def0bc119a18 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 12 Jun 2026 11:44:29 +0200 Subject: [PATCH 2/2] fix: cleanup code --- .../src/components/modals/ArticlePostModal.tsx | 2 +- .../src/components/modals/CollectionPostModal.tsx | 2 +- .../src/components/modals/SharePostModal.tsx | 2 +- .../src/components/post/PostSidebarAdWidget.tsx | 9 +++++---- .../components/post/focus/CollectionSources.tsx | 4 ++-- packages/webapp/__tests__/PostPage.tsx | 15 --------------- packages/webapp/pages/posts/[id]/index.tsx | 11 +---------- 7 files changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/components/modals/ArticlePostModal.tsx b/packages/shared/src/components/modals/ArticlePostModal.tsx index 6e6a1103334..49d93e925c7 100644 --- a/packages/shared/src/components/modals/ArticlePostModal.tsx +++ b/packages/shared/src/components/modals/ArticlePostModal.tsx @@ -39,7 +39,7 @@ export default function ArticlePostModal({ post={post} onAfterOpen={onLoad} size={showRedesign ? Modal.Size.Large : Modal.Size.XLarge} - className={showRedesign ? 'laptop:!overflow-visible' : undefined} + className={showRedesign ? 'laptop:!overflow-clip' : undefined} onRequestClose={onRequestClose} postType={PostType.Article} source={post.source} diff --git a/packages/shared/src/components/modals/CollectionPostModal.tsx b/packages/shared/src/components/modals/CollectionPostModal.tsx index 9cf86c30cf7..62e893b8231 100644 --- a/packages/shared/src/components/modals/CollectionPostModal.tsx +++ b/packages/shared/src/components/modals/CollectionPostModal.tsx @@ -41,7 +41,7 @@ export default function CollectionPostModal({ post={post} onAfterOpen={onLoad} size={showRedesign ? Modal.Size.Large : Modal.Size.XLarge} - className={showRedesign ? 'laptop:!overflow-visible' : undefined} + className={showRedesign ? 'laptop:!overflow-clip' : undefined} onRequestClose={onRequestClose} postType={PostType.Collection} source={post.source} diff --git a/packages/shared/src/components/modals/SharePostModal.tsx b/packages/shared/src/components/modals/SharePostModal.tsx index e604fdfb357..4ae2b997800 100644 --- a/packages/shared/src/components/modals/SharePostModal.tsx +++ b/packages/shared/src/components/modals/SharePostModal.tsx @@ -41,7 +41,7 @@ export default function PostModal({ post={post} onAfterOpen={onLoad} size={showRedesign ? Modal.Size.Large : Modal.Size.XLarge} - className={showRedesign ? 'laptop:!overflow-visible' : undefined} + className={showRedesign ? 'laptop:!overflow-clip' : undefined} onRequestClose={onRequestClose} postType={PostType.Share} source={post.source} diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index d2fc64e140e..654698b766e 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -19,6 +19,7 @@ import { adImprovementsV3Feature } from '../../lib/featureManagement'; import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; import { TargetId } from '../../lib/log'; import { combinedClicks } from '../../lib/click'; +import { anchorDefaultRel } from '../../lib/strings'; import { Typography, TypographyColor, @@ -123,7 +124,7 @@ export function PostSidebarAdWidget({
onAdAction(AdActions.Click))} @@ -160,7 +161,7 @@ export function PostSidebarAdWidget({ tag="a" href={ad.link} target="_blank" - rel="noopener" + rel={anchorDefaultRel} variant={ButtonVariant.Primary} size={ButtonSize.Small} className="relative z-1 ml-auto shrink-0" @@ -202,7 +203,7 @@ export function PostSidebarAdWidget({ tag="a" href={ad.link} target="_blank" - rel="noopener" + rel={anchorDefaultRel} variant={ButtonVariant.Primary} size={ButtonSize.Small} className="relative z-1" @@ -215,7 +216,7 @@ export function PostSidebarAdWidget({ onAdAction(AdActions.Click))} diff --git a/packages/shared/src/components/post/focus/CollectionSources.tsx b/packages/shared/src/components/post/focus/CollectionSources.tsx index 95122320f06..8f334a91a02 100644 --- a/packages/shared/src/components/post/focus/CollectionSources.tsx +++ b/packages/shared/src/components/post/focus/CollectionSources.tsx @@ -11,7 +11,7 @@ import { TypographyTag, TypographyType, } from '../../typography/Typography'; -import { pluralize } from '../../../lib/strings'; +import { anchorDefaultRel, pluralize } from '../../../lib/strings'; interface CollectionSourcesProps { post: Post; @@ -52,7 +52,7 @@ export const CollectionSources = ({ { expect(screen.queryByTestId('post-focus-card')).not.toBeInTheDocument(); }); - it('should force the redesign on with ?redesign=1 while the flag is off', async () => { - mockRedesignOn = false; - mockRouterQuery({ redesign: '1' }); - renderPost(); - expect(await screen.findByTestId('post-focus-card')).toBeInTheDocument(); - }); - - it('should force the classic layout with ?redesign=0 while the flag is on', async () => { - mockRedesignOn = true; - mockRouterQuery({ redesign: '0' }); - renderPost(); - 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 () => { mockRedesignOn = true; mockRouterQuery({ author: 'true' }); diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 4b965507f79..5f9af9d1d58 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -203,20 +203,11 @@ export const PostPage = ({ feature: featurePostRedesign, shouldEvaluate: isRedesignEligible, }); - // `?redesign=1`/`0` lets us preview/force the redesign for review while the - // flag default stays off; absent, it follows the flag. - const forceRedesign = - router.query.redesign === '1' || router.query.redesign === 'true'; - const forceClassic = - router.query.redesign === '0' || router.query.redesign === '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 showRedesign = - isRedesignEligible && - !forceClassic && - !requiresClassicLayout && - (isRedesignFlagOn || forceRedesign); + isRedesignEligible && !requiresClassicLayout && isRedesignFlagOn; 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',