Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f6d9fbe
feat: add Pinterest-style post discovery mockup
tsahimatsliah Jun 2, 2026
7508344
feat: render discovery experience on the real post page behind flag
tsahimatsliah Jun 2, 2026
5d62e66
fix: show post discovery preview by default
tsahimatsliah Jun 2, 2026
a26f1f7
fix: align discovery post layout with platform reading surface
tsahimatsliah Jun 2, 2026
bbc7988
fix: remove duplicate discovery read action
tsahimatsliah Jun 2, 2026
bdce1ca
test: keep classic post page tests on classic layout
tsahimatsliah Jun 2, 2026
5611539
fix: force discovery feed to render as card grid
tsahimatsliah Jun 2, 2026
cd76374
fix: reuse production post actions in discovery layout
tsahimatsliah Jun 2, 2026
6b6ac31
fix: let discovery feed use full page width
tsahimatsliah Jun 2, 2026
5e02970
fix: reuse production post details in discovery layout
tsahimatsliah Jun 2, 2026
7788b4f
fix: use reader source strip in discovery hero
tsahimatsliah Jun 2, 2026
b97f254
fix: render discovery feed across available page width
tsahimatsliah Jun 2, 2026
edb1437
fix: restore production post details column structure
tsahimatsliah Jun 2, 2026
f83318d
fix: polish discovery discussion sidebar card
tsahimatsliah Jun 2, 2026
74dcd4d
fix: render more-like-this as three-column card grid
tsahimatsliah Jun 2, 2026
d49c6cf
fix: tighten discovery actions and comment rail spacing
tsahimatsliah Jun 2, 2026
41eeabe
fix: place action bar at top of discussion card
tsahimatsliah Jun 2, 2026
368a480
fix: remove discovery post column separator
tsahimatsliah Jun 2, 2026
d3e492d
fix: simplify discussion card actions and sharing
tsahimatsliah Jun 2, 2026
a9122fd
fix: remove duplicate discovery stats row
tsahimatsliah Jun 2, 2026
b65975b
fix: let related discovery feed use main feed columns
tsahimatsliah Jun 2, 2026
94cae4c
fix: align discovery header action icon sizes
tsahimatsliah Jun 2, 2026
3826f85
fix: make discovery toc compact and collapsible
tsahimatsliah Jun 2, 2026
ff0cb4c
fix: align keep-exploring feed spacing
tsahimatsliah Jun 2, 2026
e6677df
fix: polish discovery post details and video layout
tsahimatsliah Jun 2, 2026
6e5a6f1
fix: refine discovery left column and discussion composer
tsahimatsliah Jun 2, 2026
82f9df6
fix: set discovery columns to 768px content and 340px discussion
tsahimatsliah Jun 2, 2026
59b4482
feat: move discovery stats strip to left column above action bar
tsahimatsliah Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions packages/shared/src/components/ShareBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -130,12 +138,14 @@ export default function ShareBar({ post }: ShareBarProps): ReactElement {
onClick={() => onClick(ShareProvider.Twitter)}
label="X"
/>
<SquadsToShare
size={ButtonSize.Medium}
squadAvatarSize={ProfileImageSize.Large}
maxItems={isExpanded ? undefined : maxVisibleSquadsWhenCollapsed}
onClick={(_, squad) => onShareToSquad(squad)}
/>
{shouldShowSquadOptions && (
<SquadsToShare
size={ButtonSize.Medium}
squadAvatarSize={ProfileImageSize.Large}
maxItems={isExpanded ? undefined : maxVisibleSquadsWhenCollapsed}
onClick={(_, squad) => onShareToSquad(squad)}
/>
)}
</div>
{shouldShowToggle && (
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { Post } from '../../../graphql/posts';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery';
import { useSettingsContext } from '../../../contexts/SettingsContext';
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { TimeSortIcon } from '../../icons/Sort/Time';
import { AnalyticsIcon } from '../../icons';
import { SortCommentsBy } from '../../../graphql/comments';
import { ClickableText } from '../../buttons/ClickableText';
import Link from '../../utilities/Link';
import { largeNumberFormat } from '../../../lib';
import { canViewPostAnalytics } from '../../../lib/user';
import { webappUrl } from '../../../lib/constants';

interface DiscussionMetaBarProps {
post: Post;
className?: string;
}

/**
* Post stats + comment sort strip. Lives in the left content column above the
* action bar; the sort toggle updates the shared settings context, so the
* discussion panel re-sorts its comments accordingly.
*/
export const DiscussionMetaBar = ({
post,
className,
}: DiscussionMetaBarProps): ReactElement => {
const { user } = useAuthContext();
const { onShowUpvoted } = useUpvoteQuery();
const { sortCommentsBy: sortBy, updateSortCommentsBy: setSortBy } =
useSettingsContext();
const upvotes = post.numUpvotes || 0;
const comments = post.numComments || 0;
const canSeeAnalytics = canViewPostAnalytics({ user, post });
const isNewestFirst = sortBy === SortCommentsBy.NewestFirst;
const sortLabel = isNewestFirst ? 'Sort: Newest first' : 'Sort: Oldest first';

return (
<div
className={classNames(
'flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-2 text-text-tertiary typo-callout',
className,
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-x-4">
{upvotes > 0 && (
<ClickableText onClick={() => onShowUpvoted(post.id, upvotes)}>
{largeNumberFormat(upvotes)} Upvote{upvotes > 1 ? 's' : ''}
</ClickableText>
)}
<span>
{largeNumberFormat(comments)} Comment{comments === 1 ? '' : 's'}
</span>
{canSeeAnalytics && (
<Link
href={`${webappUrl}posts/${post.id}/analytics`}
passHref
prefetch={false}
>
<ClickableText
tag="a"
className="gap-1"
textClassName="text-text-tertiary"
>
<AnalyticsIcon />
Analytics
</ClickableText>
</Link>
)}
<Button
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Tertiary}
icon={
<TimeSortIcon
secondary
className={isNewestFirst ? undefined : 'rotate-180'}
/>
}
onClick={() =>
setSortBy(
isNewestFirst
? SortCommentsBy.OldestFirst
: SortCommentsBy.NewestFirst,
)
}
aria-label={sortLabel}
title={sortLabel}
className="!text-text-tertiary"
/>
</div>
</div>
);
};
135 changes: 135 additions & 0 deletions packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ReactElement } from 'react';
import React, { useContext, useMemo } from 'react';
import type { Post } from '../../../graphql/posts';
import Feed from '../../Feed';
import SettingsContext from '../../../contexts/SettingsContext';
import { FeedLayoutProvider } from '../../../contexts/FeedContext';
import { useAuthContext } from '../../../contexts/AuthContext';
import { ActiveFeedNameContext } from '../../../contexts';
import {
ANONYMOUS_FEED_QUERY,
FEED_BY_TAGS_QUERY,
FEED_V2_QUERY,
} from '../../../graphql/feed';
import type { AllFeedPages } from '../../../lib/query';
import { OtherFeedPage } from '../../../lib/query';
import { SharedFeedPage } from '../../utilities';
import {
getPostTopicLabel,
getPostTopicTags,
} from '../anonymousPostExperience';

export const POST_DISCOVERY_FEED_ANCHOR = 'post-discovery-feed';

interface PostDiscoveryFeedProps {
post: Post;
}

interface SectionHeaderProps {
eyebrow: string;
title: string;
description: string;
}

const SectionHeader = ({
eyebrow,
title,
description,
}: SectionHeaderProps): ReactElement => (
<header className="mb-4 flex flex-col gap-1 px-4 tablet:px-6 laptop:px-8">
<p className="text-accent-cabbage-default typo-caption1">{eyebrow}</p>
<h2 className="font-bold text-text-primary typo-title2">{title}</h2>
<p className="text-text-tertiary typo-callout">{description}</p>
</header>
);

/**
* Keeps the nested discovery feeds on the grid-card path even though the page
* route itself is a post page, which normally forces list layout.
*/
const DiscoveryFeedGridScope = ({
feedName,
children,
}: {
feedName: AllFeedPages;
children: ReactElement;
}): ReactElement => {
const settings = useContext(SettingsContext);
const settingsContextValue = useMemo(
() => ({ ...settings, insaneMode: false }),
[settings],
);

return (
<ActiveFeedNameContext.Provider value={{ feedName }}>
<SettingsContext.Provider value={settingsContextValue}>
<FeedLayoutProvider>{children}</FeedLayoutProvider>
</SettingsContext.Provider>
</ActiveFeedNameContext.Provider>
);
};

/**
* The Pinterest-style discovery surface below the focus card: a finite,
* topic-relevant rail ("more like this") followed by the infinite personalized
* feed, turning the post page into a continuous exploration loop.
*/
export const PostDiscoveryFeed = ({
post,
}: PostDiscoveryFeedProps): ReactElement => {
const { user } = useAuthContext();
const topics = getPostTopicTags(post);
const topicLabel = getPostTopicLabel(topics);
const tags = (post.tags ?? []).filter((tag): tag is string => !!tag);
const hasTags = tags.length > 0;

const mainQuery = user ? FEED_V2_QUERY : ANONYMOUS_FEED_QUERY;

return (
<div
className="flex w-full flex-col gap-10"
id={POST_DISCOVERY_FEED_ANCHOR}
>
{hasTags && (
<section aria-label="More on this topic">
<SectionHeader
eyebrow="More like this"
title={`More on ${topicLabel}`}
description="Hand-picked stories close to what you just read."
/>
<DiscoveryFeedGridScope feedName={OtherFeedPage.ExploreTag}>
<Feed
className="mt-8"
feedName={OtherFeedPage.ExploreTag}
feedQueryKey={['post-discovery-related', post.id]}
query={FEED_BY_TAGS_QUERY}
variables={{ tags }}
disableAds
allowFetchMore={false}
pageSize={9}
disableListFrame
/>
</DiscoveryFeedGridScope>
</section>
)}

<section aria-label="Discover more">
<SectionHeader
eyebrow="Keep exploring"
title="Discover more"
description="A fresh stream of developer stories, discussions, and tools."
/>
<DiscoveryFeedGridScope feedName={SharedFeedPage.Popular}>
<Feed
className="mt-8"
feedName={SharedFeedPage.Popular}
feedQueryKey={['post-discovery-more', post.id]}
query={mainQuery}
variables={{}}
disableListFrame
/>
</DiscoveryFeedGridScope>
</section>
</div>
);
};
100 changes: 100 additions & 0 deletions packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { Post } from '../../../graphql/posts';
import type { PostOrigin } from '../../../hooks/log/useLogContextData';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useLogContext } from '../../../contexts/LogContext';
import { LogEvent, TargetType } from '../../../lib/log';
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { ArrowIcon } from '../../icons';
import { BuildYourFeedWidget } from '../BuildYourFeedWidget';
import type { FocusCardLeftVariant } from './PostFocusCard';
import { PostFocusCard } from './PostFocusCard';
import { PostDiscoveryFeed } from './PostDiscoveryFeed';

interface PostDiscoveryLayoutProps {
post: Post;
origin: PostOrigin;
leftVariant?: FocusCardLeftVariant;
}

const BackToTop = (): ReactElement | null => {
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
const onScroll = (): void => {
setIsVisible(globalThis.window.scrollY > 800);
};
onScroll();
globalThis.window.addEventListener('scroll', onScroll, { passive: true });

return () => globalThis.window.removeEventListener('scroll', onScroll);
}, []);

const scrollToTop = useCallback(() => {
globalThis.window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);

if (!isVisible) {
return null;
}

return (
<Button
aria-label="Back to top"
className="fixed bottom-6 right-6 z-3 -rotate-90"
icon={<ArrowIcon />}
onClick={scrollToTop}
size={ButtonSize.Large}
type="button"
variant={ButtonVariant.Primary}
/>
);
};

export const PostDiscoveryLayout = ({
post,
origin,
leftVariant = 'rich',
}: PostDiscoveryLayoutProps): ReactElement => {
const { user } = useAuthContext();
const { logEvent } = useLogContext();

useEffect(() => {
logEvent({
event_name: LogEvent.Impression,
target_type: TargetType.Post,
target_id: post.id,
extra: JSON.stringify({ origin, surface: 'post_discovery' }),
});
// Fire once per post on this surface.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [post.id]);

return (
<div className="flex w-full flex-col bg-background-default">
<div className="mx-auto flex w-full max-w-[78rem] flex-col gap-8 px-4 py-6 tablet:px-6 laptop:px-8 laptop:py-8">
<div className="mx-auto w-full max-w-[78rem]">
<PostFocusCard
leftVariant={leftVariant}
origin={origin}
post={post}
/>
</div>

{!user && (
<div className="mx-auto w-full max-w-[64rem]">
<div className="shadow-1 overflow-hidden rounded-24 border border-border-subtlest-tertiary bg-background-subtle p-4 tablet:p-6">
<BuildYourFeedWidget />
</div>
</div>
)}
</div>

<div className="w-full pb-10">
<PostDiscoveryFeed post={post} />
</div>
<BackToTop />
</div>
);
};
Loading
Loading