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 && ( + ); + + return ( +
+ {showSortHeader && ( + + + Sort: + + + + )} +
+ openShareComment(comment, post)} + onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} + modalParentSelector={resolveModalParent} + removeTopSpacing + /> +
+
+ } + shouldHandleCommentQuery + onComposerOpenChange={setIsComposerOpen} + size={ProfileImageSize.Medium} + CommentInputOrModal={CommentInputOrModal} + renderTrigger={renderComposerTrigger} + /> + {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..62a3dc3ab39 --- /dev/null +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -0,0 +1,235 @@ +import dynamic from 'next/dynamic'; +import type { ComponentProps, ReactElement } from 'react'; +import React, { useRef } from 'react'; +import type { Post } from '../../../graphql/posts'; +import { getReadArticleHref, isVideoPost } from '../../../graphql/posts'; +import type { SourceTooltip } from '../../../graphql/sources'; +import type { PostOrigin } from '../../../hooks/log/useLogContextData'; +import usePostContent from '../../../hooks/usePostContent'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; +import PostMetadata from '../../cards/common/PostMetadata'; +import YoutubeVideo from '../../video/YoutubeVideo'; +import { LazyImage } from '../../LazyImage'; +import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { PostHeaderActions } from '../PostHeaderActions'; +import { PostContainer } from '../common'; +import { PostTagList } from '../tags/PostTagList'; +import PostToc from '../../widgets/PostToc'; +import { TruncateText } from '../../utilities'; +import { combinedClicks } from '../../../lib/click'; +import { useFeature } from '../../GrowthBookProvider'; +import { feature } from '../../../lib/featureManagement'; +import { SourceStrip } from '../reader/SourceStrip'; +import { PostDiscoveryActionBar } from './PostDiscoveryActionBar'; +import { PostDiscussionPanel } from './PostDiscussionPanel'; + +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; +} + +const ArticleLink = ({ + href, + onClick, + children, + ...props +}: ComponentProps<'a'> & { + href?: string; + onClick?: (event: React.MouseEvent) => void; +}) => { + const clickHandlers = onClick + ? combinedClicks(onClick) + : undefined; + return ( + + {children} + + ); +}; + +export const PostFocusCard = ({ + post, + origin, + leftVariant, +}: PostFocusCardProps): ReactElement => { + const isVideoType = isVideoPost(post); + const { title } = useSmartTitle(post); + const { onCopyPostLink, onReadArticle } = usePostContent({ origin, post }); + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + const showCodeSnippets = useFeature(feature.showCodeSnippets); + const focusCommentRef = useRef<() => void>(() => {}); + const readHref = getReadArticleHref(post); + const hasToc = (post.toc?.length ?? 0) > 0; + const handleImageClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle(); + }; + + return ( +
+ +
+
+ {post.source && ( + + )} + +
+ +
+

+ {title} +

+
+ + focusCommentRef.current()} + onCopyLinkClick={onCopyPostLink} + /> + + {!isVideoType && post.summary && ( +

+ {post.summary} +

+ )} + + {isVideoType ? ( +
+ + {post.summary && ( +

+ {post.summary} +

+ )} +
+ ) : ( + + + + )} + + 0 && ( + + From{' '} + + {post.domain} + + + ) + } + isVideoType={isVideoType} + readTime={post.readTime} + /> + + + + {hasToc && ( + + )} + + {showCodeSnippets && ( +
+ +
+ )} +
+
+ + +
+ ); +}; 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/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/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..5caeba24ade 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -26,12 +26,15 @@ 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 { 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 +206,22 @@ export const PostPage = ({ }, }); const featureTheme = useFeatureTheme(); + const isDiscoveryEligible = [ + PostType.Article, + PostType.VideoYouTube, + ].includes(post?.type); + 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 +297,30 @@ export const PostPage = ({ - - {shouldShowAuthBanner && + {showDiscovery ? ( + + ) : ( + + )} + {!showDiscovery && + shouldShowAuthBanner && isLaptop && (isAnonPostExperience ? (