diff --git a/packages/shared/src/components/ShareBar.tsx b/packages/shared/src/components/ShareBar.tsx index 6b9461f11cf..8a093b5034a 100644 --- a/packages/shared/src/components/ShareBar.tsx +++ b/packages/shared/src/components/ShareBar.tsx @@ -23,16 +23,24 @@ import { useAuthContext } from '../contexts/AuthContext'; interface ShareBarProps { post: Post; + visibleRows?: number; } -const visibleRows = 2; const columns = 4; const fixedOptions = 4; -const maxVisibleOptions = visibleRows * columns; -const maxVisibleSquadsWhenCollapsed = maxVisibleOptions - fixedOptions; -export default function ShareBar({ post }: ShareBarProps): ReactElement { +export default function ShareBar({ + post, + visibleRows = 2, +}: ShareBarProps): ReactElement { const [isExpanded, setIsExpanded] = useState(false); + const maxVisibleOptions = visibleRows * columns; + const maxVisibleSquadsWhenCollapsed = Math.max( + maxVisibleOptions - fixedOptions, + 0, + ); + const shouldShowSquadOptions = + isExpanded || maxVisibleSquadsWhenCollapsed > 0; const href = post.commentsPermalink; const cid = ReferralCampaignKey.SharePost; const { getShortUrl } = useGetShortUrl(); @@ -130,12 +138,14 @@ export default function ShareBar({ post }: ShareBarProps): ReactElement { onClick={() => onClick(ShareProvider.Twitter)} label="X" /> - onShareToSquad(squad)} - /> + {shouldShowSquadOptions && ( + onShareToSquad(squad)} + /> + )} {shouldShowToggle && ( + + {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.tsx b/packages/shared/src/components/post/common.tsx index e631692ac7b..b903d90d8f5 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -12,7 +12,7 @@ import type { UsePostContent, UsePostContentProps, } from '../../hooks/usePostContent'; -import type { ButtonSize } from '../buttons/common'; +import type { ButtonSize, ButtonVariant } from '../buttons/common'; export interface PostContentClassName { container?: string; @@ -69,6 +69,10 @@ export interface PostHeaderActionsProps { isFixedNavigation?: boolean; buttonSize?: ButtonSize; hideSubscribeAction?: boolean; + /** Hides the trailing "..." menu (e.g. when it is surfaced elsewhere). */ + hideMenuOptions?: boolean; + /** Overrides the read button variant on desktop (defaults to Secondary). */ + readButtonVariant?: ButtonVariant; } export interface PostContentProps 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/experience/PostHero.tsx b/packages/shared/src/components/post/experience/PostHero.tsx index 7f835c12bd8..e6a26c13873 100644 --- a/packages/shared/src/components/post/experience/PostHero.tsx +++ b/packages/shared/src/components/post/experience/PostHero.tsx @@ -20,6 +20,7 @@ interface PostHeroProps { title: string; isVideoType?: boolean; metadata?: ReactNode; + sourceInfo?: ReactNode; onReadArticle?: () => void; onImageClick?: MouseEventHandler; onClose?: MouseEventHandler | React.KeyboardEventHandler; @@ -32,6 +33,7 @@ export const PostHero = ({ title, isVideoType, metadata, + sourceInfo, onReadArticle, onImageClick, onClose, @@ -46,13 +48,15 @@ export const PostHero = ({
- + {sourceInfo ?? ( + + )}
External article discussed on daily.dev diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 1ac4bda6a88..905c4f520e4 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -1,47 +1,28 @@ import dynamic from 'next/dynamic'; -import type { LegacyRef, ReactElement } from 'react'; -import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import React, { useCallback, useContext, useRef } from 'react'; import classNames from 'classnames'; -import AuthContext, { useAuthContext } from '../../../contexts/AuthContext'; +import AuthContext from '../../../contexts/AuthContext'; import type { Post } from '../../../graphql/posts'; import { isVideoPost } from '../../../graphql/posts'; import { SourceType } from '../../../graphql/sources'; import type { SourceTooltip } from '../../../graphql/sources'; -import { useShareComment } from '../../../hooks/useShareComment'; -import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import { Origin } from '../../../lib/log'; import EntityCardSkeleton from '../../cards/entity/EntityCardSkeleton'; import FurtherReading from '../../widgets/FurtherReading'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; -import { PostComments } from '../PostComments'; -import type { NewCommentRef } from '../NewComment'; -import { NewComment } from '../NewComment'; import { PostTagList } from '../tags/PostTagList'; import PostMetadata from '../../cards/common/PostMetadata'; -import { useSettingsContext } from '../../../contexts/SettingsContext'; import ShowMoreContent from '../../cards/common/ShowMoreContent'; -import { - Button, - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../buttons/Button'; -import { TimeSortIcon } from '../../icons/Sort/Time'; -import { AnalyticsIcon, ArrowIcon } from '../../icons'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ArrowIcon } from '../../icons'; import { PostMenuOptions } from '../PostMenuOptions'; -import { SortCommentsBy } from '../../../graphql/comments'; import { Tooltip } from '../../tooltip/Tooltip'; -import { ClickableText } from '../../buttons/ClickableText'; -import Link from '../../utilities/Link'; -import { largeNumberFormat } from '../../../lib'; -import { canViewPostAnalytics } from '../../../lib/user'; -import { webappUrl } from '../../../lib/constants'; -import { ProfileImageSize } from '../../ProfilePicture'; import { PostPosition } from '../../../hooks/usePostModalNavigation'; import { SourceStrip } from './SourceStrip'; import { ReaderRailActionBar } from './ReaderRailActionBar'; -import ShareBar from '../../ShareBar'; import { ReaderCloseButton } from './ReaderHeaderActionButtons'; +import { PostDiscussionPanel } from '../discovery/PostDiscussionPanel'; const SquadEntityCard = dynamic( () => @@ -53,13 +34,6 @@ const SquadEntityCard = dynamic( }, ); -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ '../../comments/CommentInputOrModal' - ), -); - type EngagementRailProps = { post: Post; postPosition?: PostPosition; @@ -80,8 +54,6 @@ type EngagementRailProps = { inlineHeaderMenu?: boolean; }; -const noopFocus = (): void => {}; - export function EngagementRail({ post, postPosition, @@ -93,33 +65,23 @@ export function EngagementRail({ inlineHeaderMenu = false, }: EngagementRailProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); - const { user } = useAuthContext(); - const { sortCommentsBy: sortBy, updateSortCommentsBy: setSortBy } = - useSettingsContext(); - const commentRef = useRef(null); - const [isComposerOpen, setIsComposerOpen] = useState(false); - const { onShowUpvoted } = useUpvoteQuery(); - const { openShareComment } = useShareComment(Origin.ReaderModal); const isVideoType = isVideoPost(post); - const upvotes = post.numUpvotes || 0; - const comments = post.numComments || 0; - const canSeeAnalytics = canViewPostAnalytics({ user, post }); - useEffect(() => { - const run = (): void => { - commentRef.current?.onShowInput(Origin.PostCommentButton); - }; - onRegisterFocusComment(run); - return () => { - onRegisterFocusComment(noopFocus); - }; - }, [onRegisterFocusComment, post.id]); + // The discussion composer lives inside PostDiscussionPanel; keep a local + // handle so the summary action bar's "comment" button can focus it too, while + // still forwarding registration up to the reader's floating action bar. + const focusCommentRef = useRef<() => void>(() => {}); + const registerFocusComment = useCallback( + (fn: () => void) => { + focusCommentRef.current = fn; + onRegisterFocusComment(fn); + }, + [onRegisterFocusComment], + ); const { source } = post; const showNavigation = !!onPreviousPost || !!onNextPost; - const isNewestFirst = sortBy === SortCommentsBy.NewestFirst; - const sortLabel = isNewestFirst ? 'Sort: Newest first' : 'Sort: Oldest first'; const railHeaderGroupClasses = 'flex h-9 items-center gap-px rounded-12 border border-border-subtlest-tertiary bg-background-default/70 p-px shadow-3 backdrop-blur-md backdrop-saturate-150'; const iconButtonClassName = '!h-8 !w-8 !min-w-8 !rounded-10 !p-0'; @@ -244,90 +206,20 @@ export function EngagementRail({ /> - commentRef.current?.onShowInput(Origin.PostCommentButton) - } + onCommentClick={() => focusCommentRef.current()} className="mt-1" />
-
-
-
- {upvotes > 0 && ( - onShowUpvoted(post.id, upvotes)}> - {largeNumberFormat(upvotes)} Upvote{upvotes > 1 ? 's' : ''} - - )} - - {largeNumberFormat(comments)} Comment - {comments === 1 ? '' : 's'} - - {canSeeAnalytics && ( - - - - Analytics - - - )} -
- -
- } - shouldHandleCommentQuery - onComposerOpenChange={setIsComposerOpen} - size={ProfileImageSize.Medium} - CommentInputOrModal={CommentInputOrModal} - /> - openShareComment(comment, post)} - onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} - modalParentSelector={() => - document.getElementById('reader-post-modal-root') ?? document.body - } - /> - -
+ + document.getElementById('reader-post-modal-root') ?? document.body + } + /> +
+ 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 9be458baacf..f151ed8135a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -40,6 +40,10 @@ export const featureAnonymousPostExperience = new Feature( 'anonymous_post_experience', true, ); +export const featurePostDiscoveryExperience = new Feature( + 'post_discovery_experience', + true, +); // @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 73d83ce3ae6..bc346985b3e 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -302,6 +302,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 8caab583e4e..09d9bb38daf 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -90,6 +90,9 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ if (args?.feature?.id === 'reader_modal') { return { value: false, isLoading: false }; } + if (args?.feature?.id === 'post_discovery_experience') { + return { value: false, isLoading: false }; + } return { value: args?.feature?.defaultValue, isLoading: false }; }, })); diff --git a/packages/webapp/pages/posts/[id]/discovery.tsx b/packages/webapp/pages/posts/[id]/discovery.tsx new file mode 100644 index 00000000000..e72c8137e65 --- /dev/null +++ b/packages/webapp/pages/posts/[id]/discovery.tsx @@ -0,0 +1,175 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import type { ParsedUrlQuery } from 'querystring'; +import type { ClientError } from 'graphql-request'; +import type { NextSeoProps } from 'next-seo/lib/types'; +import Head from 'next/head'; +import classNames from 'classnames'; +import type { Post, PostData } from '@dailydotdev/shared/src/graphql/posts'; +import { POST_BY_ID_STATIC_FIELDS_QUERY } from '@dailydotdev/shared/src/graphql/posts'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { usePostById } from '@dailydotdev/shared/src/hooks'; +import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { ActivePostContextProvider } from '@dailydotdev/shared/src/contexts/ActivePostContext'; +import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import type { FocusCardLeftVariant } from '@dailydotdev/shared/src/components/post/discovery/PostFocusCard'; +import { PostDiscoveryLayout } from '@dailydotdev/shared/src/components/post/discovery/PostDiscoveryLayout'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { getLayout } from '../../../components/layouts/MainLayout'; +import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; +import { getSeoDescription } from '../../../components/PostSEOSchema'; +import { getPageSeoTitles } from '../../../components/layouts/utils'; +import type { DynamicSeoProps } from '../../../components/common'; +import { seoTitle } from './index'; + +export interface Props extends DynamicSeoProps { + id: string; + initialData?: PostData; + error?: ApiError; +} + +interface VariantControlProps { + leftVariant: FocusCardLeftVariant; + onChange: (variant: FocusCardLeftVariant) => void; +} + +const VariantControl = ({ + leftVariant, + onChange, +}: VariantControlProps): ReactElement => { + const options: { id: FocusCardLeftVariant; label: string }[] = [ + { id: 'lean', label: 'Lean' }, + { id: 'rich', label: 'Rich' }, + ]; + + return ( +
+ Mockup · content +
+ {options.map((option) => ( + + ))} +
+
+ ); +}; + +export const PostDiscoveryPage = ({ id, initialData }: Props): ReactElement => { + const [leftVariant, setLeftVariant] = useState('rich'); + const { post, isError, isLoading } = usePostById({ + id, + options: { initialData, retry: false }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!post || isError) { + return ( +
+ Post not found +
+ ); + } + + return ( + + + + + + + + + + ); +}; + +PostDiscoveryPage.getLayout = getLayout; +PostDiscoveryPage.layoutProps = { + screenCentered: false, +}; + +export default PostDiscoveryPage; + +export interface PostParams extends ParsedUrlQuery { + id: string; +} + +export async function getStaticPaths(): Promise { + return { paths: [], fallback: 'blocking' }; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise> { + if (!params?.id) { + return { notFound: true, revalidate: 60 }; + } + + const { id } = params; + try { + const initialData = await gqlClient.request( + POST_BY_ID_STATIC_FIELDS_QUERY, + { id }, + ); + const post = initialData.post as Post; + const pageSeoTitles = getPageSeoTitles(seoTitle(post) ?? ''); + const seo: NextSeoProps = { + canonical: post?.slug ? `${webappUrl}posts/${post.slug}` : undefined, + title: pageSeoTitles.title, + description: getSeoDescription(post), + // This is an internal mockup surface; keep it out of the index. + noindex: true, + nofollow: true, + }; + + return { + props: { id: initialData.post.id, initialData, seo }, + revalidate: 60, + }; + } catch (err) { + const clientError = err as ClientError; + const errorCode = clientError?.response?.errors?.[0]?.extensions?.code; + if (errorCode === ApiError.NotFound) { + return { notFound: true, revalidate: 60 }; + } + + return { props: { id, error: errorCode as ApiError }, revalidate: 60 }; + } +} diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 682d087d756..5d205e79b5d 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -26,12 +26,16 @@ import type { PostContentProps } from '@dailydotdev/shared/src/components/post/c import { useScrollTopOffset } from '@dailydotdev/shared/src/hooks/useScrollTopOffset'; import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log'; import { + useConditionalFeature, useEventListener, useJoinReferral, usePostById, useViewSize, ViewSize, } from '@dailydotdev/shared/src/hooks'; +import { isPostDiscoveryEligible } from '@dailydotdev/shared/src/hooks/post/usePostDiscoveryExperience'; +import { featurePostDiscoveryExperience } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostDiscoveryLayout } from '@dailydotdev/shared/src/components/post/discovery/PostDiscoveryLayout'; import { usePrivateSourceJoin } from '@dailydotdev/shared/src/hooks/source/usePrivateSourceJoin'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import PostLoadingSkeleton from '@dailydotdev/shared/src/components/post/PostLoadingSkeleton'; @@ -203,6 +207,19 @@ export const PostPage = ({ }, }); const featureTheme = useFeatureTheme(); + const isDiscoveryEligible = isPostDiscoveryEligible(post); + const { value: isDiscoveryFlagOn } = useConditionalFeature({ + feature: featurePostDiscoveryExperience, + shouldEvaluate: isDiscoveryEligible, + }); + 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 containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', [ @@ -278,25 +295,30 @@ export const PostPage = ({ - - {shouldShowAuthBanner && + {showDiscovery ? ( + + ) : ( + + )} + {!showDiscovery && + shouldShowAuthBanner && isLaptop && (isAnonPostExperience ? (