From ef1b291f50eed987a66cf7be28e8aa5d5968000e Mon Sep 17 00:00:00 2001 From: idoshamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:08:59 +0000 Subject: [PATCH] chore: remove post_page_feed flag, keep the post page ending at the comments --- packages/shared/src/components/Feed.spec.tsx | 56 - packages/shared/src/components/Feed.tsx | 13 - .../components/feeds/UnifiedMobileFeedNav.tsx | 6 +- .../src/components/post/BasePostContent.tsx | 2 - .../src/components/post/PostContent.tsx | 9 +- .../post/SocialTwitterPostContent.tsx | 8 +- .../src/components/post/SquadPostContent.tsx | 8 +- .../post/collection/CollectionPostContent.tsx | 6 +- .../shared/src/components/post/common.tsx | 18 - .../post/feed/PostPageFeed.module.css | 46 - .../post/feed/PostPageFeed.spec.tsx | 126 -- .../src/components/post/feed/PostPageFeed.tsx | 135 -- .../components/post/poll/PollPostContent.tsx | 8 +- .../layout/SquadDirectoryNavbarItem.tsx | 5 +- packages/shared/src/lib/featureManagement.ts | 4 - .../experiments/PostPageFeed.stories.tsx | 240 ---- .../stories/experiments/postPageFeed/api.tsx | 136 -- .../stories/experiments/postPageFeed/data.ts | 64 - .../keepReading/IMPLEMENTATION.md | 143 -- .../keepReading/KeepReading.stories.tsx | 1195 ----------------- .../postPageFeed/keepReading/KeepReading.tsx | 547 -------- .../keepReading/KeepReadingVideo.tsx | 1031 -------------- .../postPageFeed/keepReading/markdown.tsx | 200 --- .../postPageFeed/keepReading/raw.d.ts | 4 - .../stories/experiments/postPageFeed/kit.tsx | 38 - .../stories/experiments/postPageFeed/page.tsx | 249 ---- .../experiments/postPageFeed/shell.tsx | 226 ---- packages/webapp/__tests__/PostPage.tsx | 59 - packages/webapp/pages/posts/[id]/index.tsx | 28 +- 29 files changed, 12 insertions(+), 4598 deletions(-) delete mode 100644 packages/shared/src/components/post/feed/PostPageFeed.module.css delete mode 100644 packages/shared/src/components/post/feed/PostPageFeed.spec.tsx delete mode 100644 packages/shared/src/components/post/feed/PostPageFeed.tsx delete mode 100644 packages/storybook/stories/experiments/PostPageFeed.stories.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/api.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/data.ts delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/IMPLEMENTATION.md delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/KeepReading.stories.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/KeepReading.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/KeepReadingVideo.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/markdown.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/keepReading/raw.d.ts delete mode 100644 packages/storybook/stories/experiments/postPageFeed/kit.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/page.tsx delete mode 100644 packages/storybook/stories/experiments/postPageFeed/shell.tsx diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index ce998f64b73..150a3b5aa8f 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -3490,62 +3490,6 @@ describe('Feed excludedPostIds', () => { }); }); -describe('Feed footer and errorScreen', () => { - const lastPage: Connection = { - ...defaultFeedPage, - pageInfo: { ...defaultFeedPage.pageInfo, hasNextPage: false }, - }; - - it('shows the footer after the last page', async () => { - renderComponent( - [createFeedMock(lastPage)], - defaultUser, - SharedFeedPage.MyFeed, - ANONYMOUS_FEED_QUERY, - { footer:

End of the feed

}, - ); - - expect(await screen.findByText('End of the feed')).toBeInTheDocument(); - }); - - it('hides the footer while another page follows', async () => { - renderComponent( - [ - createFeedMock({ - ...lastPage, - pageInfo: { ...lastPage.pageInfo, hasNextPage: true }, - }), - ], - defaultUser, - SharedFeedPage.MyFeed, - ANONYMOUS_FEED_QUERY, - { footer:

End of the feed

}, - ); - - await waitFor(() => - expect(screen.queryAllByTestId('postItem').length).toBeGreaterThan(0), - ); - expect(screen.queryByText('End of the feed')).not.toBeInTheDocument(); - }); - - it('shows the errorScreen when the first page fails', async () => { - renderComponent( - [ - { - request: { query: ANONYMOUS_FEED_QUERY, variables }, - result: { errors: [{ message: 'Unexpected error' }] }, - }, - ], - defaultUser, - SharedFeedPage.Custom, - ANONYMOUS_FEED_QUERY, - { errorScreen:

Could not load

}, - ); - - expect(await screen.findByText('Could not load')).toBeInTheDocument(); - }); -}); - describe('Feed excludePinnedPosts', () => { const [firstEdge, secondEdge, ...restEdges] = defaultFeedPage.edges; const pinnedTitle = getPostTitle(firstEdge.node, 'pinned'); diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index ee4a504c6e9..c3a66c1c3f5 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -124,10 +124,6 @@ export interface FeedProps feedContainerRef?: React.Ref; disableListFrame?: boolean; topContent?: ReactNode; - /** Rendered after the last post once the feed cannot fetch more. */ - footer?: ReactNode; - /** Shown instead of the feed when its first page fails to load. */ - errorScreen?: ReactNode; /** * Search feeds only: correlation id for the query execution and the backend * search version behind it. Both ride along on every engagement event so the @@ -195,8 +191,6 @@ export default function Feed({ disableListFrame = false, excludePinnedPosts = false, topContent: topContentProp, - footer, - errorScreen, searchId, searchVersion, isExplaining = false, @@ -702,10 +696,6 @@ export default function Feed({ } }; - if (errorScreen && feedError && !items.length) { - return <>{errorScreen}; - } - if (isError) { return ; } @@ -851,9 +841,6 @@ export default function Feed({ {!isFetching && !isInitialLoading && !isHorizontal && ( )} - {!!footer && !canFetchMore && !isFetching && !isInitialLoading && ( -
{footer}
- )} {selectedPost && PostModal && (!shouldUseListFeedLayout || diff --git a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx index 41cb41eb8b5..4768389b478 100644 --- a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx +++ b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx @@ -36,11 +36,11 @@ interface ChipItem { const GROUP_ORDER: ChipGroup[] = ['forYou', 'categories', 'rest']; -export const chipBaseClass = +const chipBaseClass = 'shell-press shrink-0 rounded-10 border px-2.5 py-1.5 font-bold typo-callout'; -export const chipActiveClass = +const chipActiveClass = 'border-border-subtlest-tertiary bg-surface-float text-text-primary hover:bg-surface-hover'; -export const chipInactiveClass = +const chipInactiveClass = 'border-transparent text-text-tertiary hover:text-text-primary'; interface UnifiedMobileFeedNavProps { diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index d0cacfb7244..1954bbef1b9 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -42,7 +42,6 @@ export function BasePostContent({ engagementProps, shouldOnboardAuthor, aboveComments, - belowComments, commentAds, isPostPage, boostOrigin, @@ -105,7 +104,6 @@ export function BasePostContent({ actionsClassName={hasCapsule ? 'hidden tablet:flex' : undefined} /> )} - {belowComments} ); } diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 56fb3115250..6b372eb19df 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -14,7 +14,7 @@ import usePostContent from '../../hooks/usePostContent'; import { BasePostContent } from './BasePostContent'; import { combinedClicks } from '../../lib/click'; import type { PostContentProps, PostNavigationProps } from './common'; -import { PostContainer, getPostWidgetsClassName } from './common'; +import { PostContainer } from './common'; import YoutubeVideo from '../video/YoutubeVideo'; import { useTrackPostView } from '../../hooks/post/useTrackPostView'; import { TruncateText } from '../utilities'; @@ -94,7 +94,6 @@ export function PostContentRaw({ contentLeading, renderSummarySegments, aboveComments, - belowComments, commentAds, shouldTrackView, }: PostContentRawProps): ReactElement { @@ -174,7 +173,6 @@ export function PostContentRaw({ {contentLeading} & { @@ -55,7 +54,6 @@ export function SocialTwitterPostContentRaw({ onClose, isBannerVisible, isPostPage, - belowComments, shouldTrackView = true, }: SocialTwitterPostContentRawProps): ReactElement { const isBoostButtonVisible = useShowBoostButton({ post }); @@ -150,7 +148,6 @@ export function SocialTwitterPostContentRaw({ }, }} isPostPage={isPostPage} - belowComments={belowComments} isFallback={isFallback} customNavigation={customNavigation} shouldOnboardAuthor={shouldOnboardAuthor} @@ -239,10 +236,7 @@ export function SocialTwitterPostContentRaw({ onCopyPostLink={onCopyPostLink} onReadArticle={onReadArticle} post={post} - className={getPostWidgetsClassName( - 'mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0', - !!belowComments, - )} + className="mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0" onClose={onClose} origin={origin} /> diff --git a/packages/shared/src/components/post/SquadPostContent.tsx b/packages/shared/src/components/post/SquadPostContent.tsx index d1ec5c6335b..9c7ada4f664 100644 --- a/packages/shared/src/components/post/SquadPostContent.tsx +++ b/packages/shared/src/components/post/SquadPostContent.tsx @@ -31,7 +31,6 @@ import { mapCommunitySentimentPost, } from './focus/CommunitySentiment'; import { SelectionSnapshotBar } from '../../features/snapshot/SelectionSnapshotBar'; -import { getPostWidgetsClassName } from './common'; import { Origin } from '../../lib/log'; // The three bodies share one call site, so they share one props shape; each @@ -83,7 +82,6 @@ export function SquadPostContentRaw({ onClose, isBannerVisible, isPostPage, - belowComments, shouldTrackView, }: SquadPostContentRawProps): ReactElement { const contentRef = useRef(null); @@ -181,7 +179,6 @@ export function SquadPostContentRaw({ }, }} isPostPage={isPostPage} - belowComments={belowComments} isFallback={isFallback} customNavigation={customNavigation} shouldOnboardAuthor={shouldOnboardAuthor} @@ -251,10 +248,7 @@ export function SquadPostContentRaw({ onCopyPostLink={onCopyPostLink} onReadArticle={onReadArticle} post={post} - className={getPostWidgetsClassName( - 'mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0', - !!belowComments, - )} + className="mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0" onClose={onClose} origin={origin} /> diff --git a/packages/shared/src/components/post/collection/CollectionPostContent.tsx b/packages/shared/src/components/post/collection/CollectionPostContent.tsx index d47c68614da..f9650c9ae23 100644 --- a/packages/shared/src/components/post/collection/CollectionPostContent.tsx +++ b/packages/shared/src/components/post/collection/CollectionPostContent.tsx @@ -13,7 +13,7 @@ import { ParagraphSnapshotButtons } from '../../../features/snapshot/ParagraphSn import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar'; import { CollectionPostWidgets } from './CollectionPostWidgets'; import type { PostContentProps, PostNavigationProps } from '../common'; -import { PostContainer, getPostWidgetsClassName } from '../common'; +import { PostContainer } from '../common'; import { Pill } from '../../Pill'; import { CollectionsIntro } from '../widgets'; import { webappUrl } from '../../../lib/constants'; @@ -53,7 +53,6 @@ export const CollectionPostContentRaw = ({ backToSquad, isBannerVisible, isPostPage, - belowComments, shouldTrackView, }: CollectionPostContentRawProps): ReactElement => { const { subject } = useToastNotification(); @@ -133,7 +132,6 @@ export const CollectionPostContentRaw = ({ }, }} isPostPage={isPostPage} - belowComments={belowComments} isFallback={isFallback} customNavigation={customNavigation} shouldOnboardAuthor={shouldOnboardAuthor} @@ -203,7 +201,7 @@ export const CollectionPostContentRaw = ({ diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx index 07b26b4710d..ddbbeb5d735 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import type { CSSProperties, KeyboardEventHandler, @@ -110,12 +109,6 @@ export interface PostContentProps renderSummarySegments?: (summary: string) => ReactNode; /** Rendered between the article content and the engagement block. */ aboveComments?: ReactNode; - /** - * Rendered after the comments, at the end of the post's main column. Only - * the webapp post page passes it (the post page feed): the modal, the - * reader and the history pane render the same component without it. - */ - belowComments?: ReactNode; /** Interleaves the comment thread — see PostEngagements. */ commentAds?: { interleaveEvery: number; @@ -146,7 +139,6 @@ export interface BasePostContentProps extends UsePostContentProps { engagementProps?: UsePostContent; shouldOnboardAuthor?: boolean; aboveComments?: ReactNode; - belowComments?: ReactNode; commentAds?: { interleaveEvery: number; renderInterleaved: (occurrence: number) => ReactNode; @@ -156,13 +148,3 @@ export interface BasePostContentProps extends UsePostContentProps { isPostPage?: boolean; boostOrigin?: Origin; } - -/** - * The widgets column under a post's main column. Below laptop the page is one - * column, so with a feed after the comments (`belowComments`) the widgets - * would sit under an endless feed. Keep them for laptop up only. - */ -export const getPostWidgetsClassName = ( - className: string, - hasBelowComments: boolean, -): string => classNames(className, hasBelowComments && 'hidden laptop:flex'); diff --git a/packages/shared/src/components/post/feed/PostPageFeed.module.css b/packages/shared/src/components/post/feed/PostPageFeed.module.css deleted file mode 100644 index 1c8f08ba0dc..00000000000 --- a/packages/shared/src/components/post/feed/PostPageFeed.module.css +++ /dev/null @@ -1,46 +0,0 @@ -/* - * The post page feed's list: production list cards, flush with the post's - * main column. The section cancels the column's padding, so each card takes - * the post's own inset (--post-feed-gutter) and runs edge to edge, its - * divider meeting the column's borders. No rounded corners: the cards are - * rows of the page, not tiles in a feed. - */ -.list { - container: post-page-feed / inline-size; - - & :global(article) { - border-radius: 0; - padding-left: var(--post-feed-gutter); - padding-right: var(--post-feed-gutter); - } -} - -/* - * List cards go side by side by WINDOW width (mobileXL, 500px), but here - * they sit in the post's column: on a 1020-1130px laptop it is far - * narrower than the side-by-side card needs (a 240px image, a 16px gap and - * a text column that cannot shrink below its ~234px action bar, plus the - * gutters). So below 554px of column they stay stacked, as on a phone. - */ -@container post-page-feed (max-width: 553px) { - .list :global(article .mobileXL\:flex-row) { - flex-direction: column; - } - - .list - :global(article :is(.mobileXL\:w-40, .mobileXL\:w-60, .mobileXXL\:w-56)) { - width: 100%; - } - - .list :global(article :is(.mobileXL\:max-h-40, .mobileXXL\:max-h-56)) { - max-height: 12.5rem; - } - - .list :global(article .mobileXL\:mt-0) { - margin-top: 1rem; - } - - .list :global(article :is(.mobileXL\:self-start, .mobileXXL\:self-start)) { - align-self: auto; - } -} diff --git a/packages/shared/src/components/post/feed/PostPageFeed.spec.tsx b/packages/shared/src/components/post/feed/PostPageFeed.spec.tsx deleted file mode 100644 index 1e705ab087c..00000000000 --- a/packages/shared/src/components/post/feed/PostPageFeed.spec.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import { QueryClient } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import { useInView } from 'react-intersection-observer'; -import { TestBootProvider } from '../../../../__tests__/helpers/boot'; -import post from '../../../../__tests__/fixture/post'; -import loggedUser from '../../../../__tests__/fixture/loggedUser'; -import type { Post } from '../../../graphql/posts'; -import { FEED_V2_QUERY } from '../../../graphql/feed'; -import { OtherFeedPage } from '../../../lib/query'; -import type { FeedProps } from '../../Feed'; -import { PostPageFeed } from './PostPageFeed'; - -type CapturedFeedProps = FeedProps>; - -const feedProps: CapturedFeedProps[] = []; -const lastFeed = (): CapturedFeedProps => feedProps[feedProps.length - 1]; - -// The production Feed fetches and renders the cards; here only what the -// section asks it for matters. -jest.mock('../../Feed', () => ({ - __esModule: true, - default: (props: CapturedFeedProps) => { - feedProps.push(props); - return
; - }, -})); - -jest.mock('react-intersection-observer', () => ({ - useInView: jest.fn(), -})); - -const mockInView = (inView: boolean) => - jest.mocked(useInView).mockReturnValue({ - ref: jest.fn(), - inView, - entry: undefined, - } as unknown as ReturnType); - -const host = { ...post, id: 'host-post' } as Post; - -const renderFeed = ( - props: Partial = {}, - client: QueryClient = new QueryClient(), -) => - render( - - - , - ); - -beforeEach(() => { - feedProps.length = 0; - mockInView(true); -}); - -describe('PostPageFeed', () => { - it('should render the reader’s For you feed, without the post itself', () => { - renderFeed(); - - expect(lastFeed()).toMatchObject({ - feedName: OtherFeedPage.Post, - feedQueryKey: ['postPageFeed', loggedUser.id, 'host-post'], - query: FEED_V2_QUERY, - excludedPostIds: ['host-post'], - disableListFrame: true, - }); - // Ads as on the home For you feed. - expect(lastFeed().disableAds).toBeFalsy(); - // For you never ends, so it has no end note. - expect(lastFeed().footer).toBeUndefined(); - expect(lastFeed().emptyScreen).toBeDefined(); - expect(lastFeed().errorScreen).toBeDefined(); - }); - - it('should title the feed More for you', () => { - renderFeed(); - - expect( - screen.getByRole('heading', { level: 2, name: 'More for you' }), - ).toBeVisible(); - expect(screen.getByTestId('postPageFeed')).toHaveAccessibleName( - 'More for you', - ); - }); - - it('should show no tabs', () => { - renderFeed(); - - expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); - ['For you', 'Following'].forEach((name) => { - expect(screen.queryByRole('button', { name })).not.toBeInTheDocument(); - }); - }); - - it('should not load the feed before the section nears the screen', () => { - mockInView(false); - renderFeed(); - - expect(screen.queryByTestId('feed')).not.toBeInTheDocument(); - expect(feedProps).toHaveLength(0); - }); - - it('should render a cached feed straight away so Back can restore into it', () => { - mockInView(false); - const client = new QueryClient(); - client.setQueryData(['postPageFeed', loggedUser.id, 'host-post'], { - pages: [], - pageParams: [], - }); - renderFeed({}, client); - - expect(screen.getByTestId('feed')).toBeInTheDocument(); - }); - - it('should key the feed by post, so another post starts its own', () => { - renderFeed({ id: 'other-post' }); - - expect(lastFeed().feedQueryKey).toEqual([ - 'postPageFeed', - loggedUser.id, - 'other-post', - ]); - expect(lastFeed().excludedPostIds).toEqual(['other-post']); - }); -}); diff --git a/packages/shared/src/components/post/feed/PostPageFeed.tsx b/packages/shared/src/components/post/feed/PostPageFeed.tsx deleted file mode 100644 index 14f6a718144..00000000000 --- a/packages/shared/src/components/post/feed/PostPageFeed.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useId, useMemo, useState } from 'react'; -import classNames from 'classnames'; -import { useQueryClient } from '@tanstack/react-query'; -import { useInView } from 'react-intersection-observer'; -import Feed from '../../Feed'; -import type { Post } from '../../../graphql/posts'; -import { baseFeedSupportedTypes, FEED_V2_QUERY } from '../../../graphql/feed'; -import { OtherFeedPage } from '../../../lib/query'; -import { feature } from '../../../lib/featureManagement'; -import { - cloudinaryCharmNoPosts, - cloudinaryCharmSearchNoResults, -} from '../../../lib/image'; -import { useFeature } from '../../GrowthBookProvider'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { ActiveFeedNameContext } from '../../../contexts/ActiveFeedNameContext'; -import { FeedLayoutProvider } from '../../../contexts/FeedContext'; -import { - CharmEmptyState, - CharmEmptyStatePlacement, -} from '../../charm/CharmEmptyState'; -import { TagLinks } from '../../TagLinks'; -import { ButtonSize } from '../../buttons/Button'; -import styles from './PostPageFeed.module.css'; - -// The post page feed (`post_page_feed`): after the comments, in the post's -// main column, under a "More for you" title, the reader's For you feed with -// the home page's query and ads. No tabs: one list that pages as the reader -// scrolls. The production Feed renders it; -// the `posts[id]` feed name already keeps it in list layout at every width. -// Signed-in readers only: the page decides (see pages/posts/[id]). - -const postFeedName = { feedName: OtherFeedPage.Post }; - -export function PostPageFeed({ post }: { post: Post }): ReactElement { - const headingId = useId(); - const { user } = useAuthContext(); - const queryClient = useQueryClient(); - const feedVersion = useFeature(feature.feedVersion); - const variables = useMemo( - () => ({ version: feedVersion, supportedTypes: baseFeedSupportedTypes }), - [feedVersion], - ); - const feedQueryKey = useMemo( - () => ['postPageFeed', user?.id ?? 'anonymous', post.id], - [post.id, user?.id], - ); - const [hasCachedFeed] = useState( - () => !!queryClient.getQueryData(feedQueryKey), - ); - const { ref: inViewRef, inView } = useInView({ - rootMargin: '1200px 0px', - triggerOnce: true, - skip: hasCachedFeed, - }); - const shouldRenderFeed = hasCachedFeed || inView; - const excludedPostIds = useMemo(() => [post.id], [post.id]); - - return ( -
-

- More for you -

-
- {shouldRenderFeed && ( - // The home feed's layout settings, so the ads follow the same - // template (the ad template experiment included) and page size. - // One column: the cards stay in list layout under the post. - - - - - {!!post.tags?.length && ( -
- -
- )} -
- } - errorScreen={ - - queryClient.resetQueries({ queryKey: feedQueryKey }), - }} - className="py-10" - /> - } - /> - - - )} -
- - ); -} diff --git a/packages/shared/src/components/post/poll/PollPostContent.tsx b/packages/shared/src/components/post/poll/PollPostContent.tsx index f18531c54c7..6ec99693f3d 100644 --- a/packages/shared/src/components/post/poll/PollPostContent.tsx +++ b/packages/shared/src/components/post/poll/PollPostContent.tsx @@ -29,7 +29,6 @@ import { Typography, TypographyType } from '../../typography/Typography'; import { DiscussIcon } from '../../icons'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import type { Post } from '../../../graphql/posts'; -import { getPostWidgetsClassName } from '../common'; type PollPostContentRawProps = Omit & { post: Post }; @@ -49,7 +48,6 @@ function PollPostContentRaw({ onClose, isBannerVisible, isPostPage, - belowComments, shouldTrackView = true, }: PollPostContentRawProps): ReactElement { const [justVoted, setJustVoted] = useState(false); @@ -167,7 +165,6 @@ function PollPostContentRaw({ }, }} isPostPage={isPostPage} - belowComments={belowComments} isFallback={isFallback} customNavigation={customNavigation} shouldOnboardAuthor={shouldOnboardAuthor} @@ -275,10 +272,7 @@ function PollPostContentRaw({ onCopyPostLink={onCopyPostLink} onReadArticle={onReadArticle} post={post} - className={getPostWidgetsClassName( - 'mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0', - !!belowComments, - )} + className="mb-6 !gap-2 border-l border-border-subtlest-tertiary pt-4 laptop:mb-0" onClose={onClose} origin={origin} /> diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryNavbarItem.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryNavbarItem.tsx index 5454a5053c8..4639b507582 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryNavbarItem.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryNavbarItem.tsx @@ -15,8 +15,6 @@ interface SquadNavbarItemProps { // Override the default "…'s directory page" label when this row is reused // outside the Squads directory (e.g. the explore / notifications headers). ariaLabel?: string; - /** Off for labels whose case matters, like tags (#react, not #React). */ - capitalize?: boolean; } export function SquadDirectoryNavbarItem({ @@ -27,13 +25,12 @@ export function SquadDirectoryNavbarItem({ onClick, elementProps = {}, ariaLabel, - capitalize = true, }: SquadNavbarItemProps): ReactElement { const button = ( - )} - - ); -}; - -/** The whole post page with A under it. */ -const Page = ({ - post, - postId, - start = 'feed', - feed = {}, -}: { - post?: Post; - postId?: string; - start?: Start; - feed?: Partial[0]>; -}): ReactElement => ( - } - /> -); - -/** - * The feed alone in the app's chrome, under the last line of a post: for - * the states, where the post above would only push the feed off screen. - */ -const FeedOnly = ({ - host, - feed = {}, -}: { - host: Post; - feed?: Partial[0]>; -}): ReactElement => ( - - - - - undefined}> - -
- … the end of the post and its comments -
- -
-
-
-); - -/** A phone and a laptop side by side, the pair every case is checked at. */ -const Pair = ({ - phone, - laptop, -}: { - phone: ReactNode; - laptop: ReactNode; -}): ReactElement => ( -
-
{phone}
- {laptop} -
-); - -/* ------------------------------------------------------------ fixtures */ - -const article = postById(DEFAULT_POST_ID) as Post; - -/** Hosts made up from a real post for the edge cases. */ -const EDGE = { - noTags: { ...article, tags: [] } as Post, - longTags: { - ...article, - tags: [ - 'developer-experience', - 'continuous-integration', - 'observability-engineering', - 'career', - ], - } as Post, - emptyTag: { - ...article, - tags: ['webassembly-components', ...(article.tags ?? [])], - } as Post, - soloSource: { - ...article, - source: { - ...article.source, - id: 'story-solo-source', - handle: 'story-solo-source', - name: 'A source with one post', - }, - } as Post, -}; - -type Edge = keyof typeof EDGE | 'none'; - -const EDGE_LABELS: Record = { - none: 'None: the post as it is', - noTags: 'Post without tags (no tag tabs)', - longTags: 'Long tag names (tab bar scrolls)', - emptyTag: 'A tag with no other posts (empty tab)', - soloSource: 'Source with no other posts (no source tab)', -}; - -/** One post of every card type, as a For you tab, for the card review. */ -const everyCardType = (): FeedTab[] => { - const pick = (type: PostType, count = 1) => - posts.filter((post) => post.type === type).slice(0, count); - const list = [ - ...pick(PostType.Article, 2), - ...pick(PostType.VideoYouTube), - ...pick(PostType.Share), - ...pick(PostType.Freeform), - ...pick(PostType.Poll), - ...pick(PostType.Collection), - ...pick(PostType.SocialTwitter), - ]; - // Finite here: the point is one card of each type, not an endless feed. - return [{ label: FOR_YOU, kind: 'forYou', posts: list, infinite: false }]; -}; - -/* ----------------------------------------------------------------- spec */ - -const Row = ({ cells }: { cells: ReactNode[] }): ReactElement => ( - - {cells.map((cell, index) => ( - - {cell} - - ))} - -); - -const Table = ({ - head, - rows, -}: { - head: string[]; - rows: ReactNode[][]; -}): ReactElement => ( -
- - - - {head.map((cell) => ( - - ))} - - - - {rows.map((cells, index) => ( - // eslint-disable-next-line react/no-array-index-key - - ))} - -
- {cell} -
-
-); - -const code = (text: string): ReactElement => ( - {text} -); - -const SpecPage = (): ReactElement => ( - -

- After the comments, the post page keeps going: a list of posts like - the one just read, in production’s list cards, with tabs for the - post’s topics and its source. Click a card and that post opens in - place, with its own feed under it. -

-

- Every page here runs production’s post page and production’s - components at a real window width. The Playground has every control; - the other pages lay out every breakpoint, post type, card type, state - and tab case side by side. -

- - } - > -
- - {code('ShellRow')} + {code('Segments')} - , - <> - The home feed’s own components (HomeSegments), pinned under the - page row while the feed scrolls. No visible title at any width: - like the home feed, the tabs say what the list is (the section - keeps a heading for screen readers). - , - ], - [ - 'Tabs, tablet', - 'UnifiedMobileFeedNav’s chips', - <> - The home feed’s chip strip below laptop, class for class:{' '} - {code('rounded-10 px-2.5 py-1.5 typo-callout')}, the active chip - on {code('bg-surface-float')}, a bottom line. Not pinned: the - tablet post column keeps its overflow-hidden. - , - ], - [ - 'Tabs, laptop', - 'The v2 page header + ExploreChipsBar', - <> - The home feed’s strip in the new layout ({code('min-h-14')}, - quaternary bottom line) with ExploreChipsBar’s tabs: small - buttons, the active one Float with a short underline, a fade where - they overflow. - , - ], - [ - 'Cards', - code('PostTypeToListCard'), - <> - The list cards the phone feed uses, every type. The feed is{' '} - {code('posts[id]')}, which useFeedLayout already keeps in list - layout at every width. Flush with the column: square corners, the - post’s own inset, dividers edge to edge. - , - ], - [ - 'Layout', - 'The post’s own columns', - <> - No frame of its own: the feed continues the post’s main column - (same width, same side borders, same inset as the post’s text). - The side column beside it stays empty: no widgets, the feed is the - page. The tab bar’s line and every card divider run the full width - of the column. - , - ], - [ - 'Paging', - code('InfiniteScrolling'), - 'A page of posts at a time (seven here), the next as the last card nears the screen.', - ], - [ - 'Loading', - code('PlaceholderList'), - 'Three on first load, two while the next page loads.', - ], - [ - 'For you empty (rare)', - <> - {code('CharmEmptyState')} + {code('TagLinks')} - , - 'A brand-new account’s feed: “Your feed is empty for now” and the post’s tags to follow, wrapping. Not SimilarEmptyScreen: its tag row cannot shrink and runs off both edges on phones.', - ], - [ - 'Empty tab', - code('CharmEmptyState'), - 'Inline placement; offers For you.', - ], - [ - 'Error', - code('CharmEmptyState'), - 'Inline, not Feed’s FeedErrorScreen (a full-page “Production is down”, wrong under a working post). Feed needs an errorScreen prop for it.', - ], - [ - 'End of a tag or source tab', - New, - 'Production feeds never end, so there is no component. “You’re all caught up”, back to top, go to my feed. For you never shows it: it keeps paging, like the home feed.', - ], - ]} - /> - - -
-
The tag feed ({code('TAG_FEED_QUERY')}), without this post., - ], - [ - 'Source', - 'More from the post’s source.', - <> - The source feed ({code('SOURCE_FEED_QUERY')}), without this post. - Hidden when the source has nothing else. - , - ], - ]} - /> -

- A post with one tab (no tags, nothing else from its source) shows the - list alone, right after the comments, without a tab bar. -

- - -
-
- - -
-
- - Below laptop the feed has to sit between the comments and the side - widgets. PostContent needs a prop for it (a {code('belowComments')}{' '} - next to {code('aboveComments')}); this review places it there from - outside. - - - For you is the reader’s home feed, the tags are the tag feed, the - source is the source feed. Feed already pages them, logs them, and - drops the current post ({code('excludedPostIds')}). - - - Feed already logs impressions and clicks with{' '} - {code("extra.feed = 'posts[id]'")}. New: a tab-switch event. - - - {code('post_page_feed')}, default off, evaluated only for signed-in - readers. Signed-out readers keep today’s page (comments and the - sign-up footer) and never enter the experiment. Full steps: 0. - Implementation guide. - -
-
- -
-
- - From 500px (mobileXL) the list card puts its 240px image beside the - text, but the text column cannot get narrower than its action bar - (about 234px: buttons do not shrink). So the card needs about 522px - for that layout (554px with the post’s 32px inset). Windows 500–521px - wide scroll sideways by up to 22px, and the post’s main column on a - 1020–1130px laptop is far too narrow. The home feed uses the same card - at full width on phones, so it likely overflows there too. Fix: switch - on the card’s own width (a container query), and give it a flush - variant for this feed. The feed does both here with scoped CSS. - - - Its tag row sits in a second flex row that cannot shrink, so the tags - stay on one line and run off both edges. Unused today; fix it before - this feed uses it. - -
-
- -); - -/** - * IMPLEMENTATION.md, the guide an engineer or an agent builds from, shown - * here as is: the page and the file are the same text. - */ -export const ImplementationGuide: Story = { - name: '0. Implementation guide', - render: () => ( -
-
-

- Post page feed · Keep reading · IMPLEMENTATION.md -

- -
-
- ), -}; - -export const Spec: Story = { - name: '1. Spec', - render: () => , -}; - -/* ----------------------------------------------------------- playground */ - -const TAB_PICKS = { - forYou: FOR_YOU, - tag1: 'First tag', - tag2: 'Second tag', - tag3: 'Third tag', - source: 'Source', -} as const; - -type TabPick = keyof typeof TAB_PICKS; - -/** The label of the picked tab on this post, if it has one. */ -const pickTab = (host: Post, pick: TabPick): string => { - const tabs = tabsFor(host); - const tags = tabs.filter((tab) => tab.kind === 'tag'); - const found = { - forYou: tabs[0], - tag1: tags[0], - tag2: tags[1], - tag3: tags[2], - source: tabs.find((tab) => tab.kind === 'source'), - }[pick]; - return found?.label ?? FOR_YOU; -}; - -interface PlaygroundArgs { - size: Size; - post: string; - edge: Edge; - tab: TabPick; - state: FeedState; - pageSize: number; - start: Start; -} - -const POST_OPTIONS = [ - ...HOSTS.map((host) => host.id), - ...posts - .filter((post) => !HOSTS.some((host) => host.id === post.id)) - .map((post) => post.id), -]; - -export const Playground: StoryObj = { - name: '2. Playground', - args: { - size: LAPTOP, - post: DEFAULT_POST_ID, - edge: 'none', - tab: 'forYou', - state: 'live', - pageSize: 7, - start: 'feed', - }, - argTypes: { - size: { - name: 'Window width', - control: 'select', - options: Object.keys(SIZES), - labels: Object.fromEntries( - Object.entries(SIZES).map(([key, size]) => [key, size.label]), - ), - }, - post: { - name: 'Post', - control: 'select', - options: POST_OPTIONS, - labels: Object.fromEntries( - posts.map((post) => { - const host = HOSTS.find((entry) => entry.id === post.id); - const title = (post.title ?? post.id).slice(0, 56); - return [post.id, host ? `${host.label}: ${title}` : title]; - }), - ), - }, - edge: { - name: 'Edge case', - control: 'select', - options: Object.keys(EDGE_LABELS), - labels: EDGE_LABELS, - description: 'Built from the default article; overrides Post.', - }, - tab: { - name: 'Opens on tab', - control: 'select', - options: Object.keys(TAB_PICKS), - labels: TAB_PICKS, - description: 'Falls back to For you when the post has no such tab.', - }, - state: { - name: 'State', - control: 'select', - options: ['live', 'loading', 'loadingMore', 'end', 'empty', 'error'], - labels: { - live: 'Live: scroll to page, click tabs and cards', - loading: 'First load', - loadingMore: 'Loading the next page', - end: 'End of the tab (tag and source tabs; For you never ends)', - empty: 'Nothing similar', - error: 'Failed to load', - }, - }, - pageSize: { - name: 'Posts per page', - control: { type: 'range', min: 3, max: 15 }, - }, - start: { - name: 'Opens at', - control: 'inline-radio', - options: ['feed', 'top'], - labels: { feed: 'The end of the post', top: 'The top' }, - }, - }, - render: ({ size, post, edge, tab, state, pageSize, start }) => { - const host = edge === 'none' ? postById(post) : EDGE[edge]; - return ( -
- - - -

- Tabs for this post:{' '} - {host - ? tabsFor(host) - .map((entry) => entry.label) - .join(' · ') - : '—'} -

-
- ); - }, -}; - -/* ---------------------------------------------------------- breakpoints */ - -const BreakpointsPage = (): ReactElement => ( - - The same article at every width where the page or the feed changes. Each - window opens at the end of the post; scroll inside it to page through - the feed. -

- } - > - {( - [ - ['Phones', ['w320', 'w375', 'w420', 'w500', 'w600']], - ['Phone in landscape', ['w844l']], - ['Tablets', ['w656', 'w768']], - ['Laptops', ['w1020', 'w1140', 'w1280', 'w1360', 'w1440']], - ['Wide screens', ['w1668', 'w1920', 'w2560']], - ] as [string, Size[]][] - ).map(([title, sizes]) => ( -
-
- {sizes.map((size) => ( -
- - - -
- ))} -
-
- ))} -
-); - -export const Breakpoints: Story = { - name: '3. Breakpoints', - render: () => , -}; - -/* ----------------------------------------------------------- post types */ - -const PostTypesPage = (): ReactElement => ( - - The feed under each kind of post the post page renders, with that post’s - own tabs. Phone and laptop, each opening at the end of the post. -

- } - > - {HOSTS.map((host) => ( -
- {host.post.title} · tabs:{' '} - {tabsFor(host.post) - .map((tab) => tab.label) - .join(' · ')} - - } - > - - - - } - laptop={ - - - - } - /> -
- ))} -
-); - -export const PostTypes: Story = { - name: '4. Post types', - render: () => , -}; - -/* ----------------------------------------------------------- card types */ - -const CardTypesPage = (): ReactElement => { - const tabs = everyCardType(); - return ( - - The feed can hold any post, so it shows every production list card:{' '} - {tabs[0].posts.map((post) => post.type).join(', ')}. At the widths - where the list card changes layout: stacked, side by side, and in the - laptop frame. -

- } - > -
- {(['w375', 'w768'] as Size[]).map((size) => ( -
- - - -
- ))} -
- - - -
- ); -}; - -export const CardTypes: Story = { - name: '5. Card types', - render: () => , -}; - -/* --------------------------------------------------------------- states */ - -const STATES: { - title: string; - lead: string; - host?: Post; - feed?: Partial[0]>; -}[] = [ - { - title: 'First load', - lead: 'Three list placeholders under the tab bar while the first page loads.', - feed: { state: 'loading' }, - }, - { - title: 'Loading the next page', - lead: 'Two placeholders after the last card, as InfiniteScrolling fetches.', - feed: { state: 'loadingMore', pageSize: 3 }, - }, - { - title: 'End of a tag or source tab', - lead: 'Every post shown: the end note, back to top and the way to the feed. For you never gets here.', - feed: { state: 'end', initialTab: '#github' }, - }, - { - title: 'A tag with no other posts', - lead: 'The tab still shows (the post has the tag); its empty state offers For you.', - host: EDGE.emptyTag, - feed: { initialTab: '#webassembly-components' }, - }, - { - title: 'For you is empty (rare)', - lead: 'A brand-new account’s empty For you: “Your feed is empty for now”, the post’s tags to follow (wrapping). No tab bar.', - feed: { state: 'empty' }, - }, - { - title: 'Failed to load', - lead: 'Inline: the post above is fine, so the message is small and offers a retry. Not Feed’s default FeedErrorScreen, a full-page “Production is down”.', - feed: { state: 'error' }, - }, - { - title: 'Post without tags', - lead: 'For you and the source only.', - host: EDGE.noTags, - }, - { - title: 'Source with no other posts', - lead: 'No source tab: it would always be empty.', - host: EDGE.soloSource, - }, - { - title: 'One tab only', - lead: 'No tags and a source with nothing else: the list alone, no tab bar.', - host: { ...EDGE.soloSource, tags: [] } as Post, - }, -]; - -const StatesPage = (): ReactElement => ( - - Each state frozen, at phone and laptop, under the last line of a post. - The live behaviour of each is in the Playground (State). -

- } - > - {STATES.map((entry) => ( -
- - - - } - laptop={ - - - - } - /> -
- ))} -
-); - -export const States: Story = { - name: '6. States', - render: () => , -}; - -/* ----------------------------------------------------------------- tabs */ - -const TabsPage = (): ReactElement => ( - - The home feed’s tabs at their tightest: Segments on phones, the chip - strip on tablets, the page header’s tabs on laptops. Long tag names - scroll sideways. Each frame opens on a different tab. -

- } - > - {( - [ - ['Short tags, For you', article, FOR_YOU], - ['Short tags, a tag tab', article, `#${article.tags?.[1] ?? 'career'}`], - [ - 'Short tags, the source tab', - article, - article.source?.name ?? FOR_YOU, - ], - [ - 'Long tags, the last tag', - EDGE.longTags, - '#observability-engineering', - ], - ] as [string, Post, string][] - ).map(([title, host, tab]) => ( -
-
- {(['w320', 'w375', 'w768'] as Size[]).map((size) => ( -
- - - -
- ))} -
- - - -
- ))} -
-); - -export const Tabs: Story = { - name: '7. Tabs', - render: () => , -}; - -/* ------------------------------------------------------------ signed out */ - -const SignedOutPage = (): ReactElement => ( - -

- The feed is for signed-in readers only. A signed-out reader has no For - you feed to continue, and their post page already has its job: the - comments, and the sticky footer that asks them to sign up. So for them - the page stays exactly as it is today, with or without the flag. -

-
    -
  • - The flag is evaluated for signed-in readers only, so signed-out - readers never enter the experiment and never count in it. -
  • -
  • - Below: a signed-out reader on the post page, as production renders - it today. Nothing follows the comments. -
  • -
- - } - > - - - - } - laptop={ - - - - } - /> -
-); - -export const SignedOut: Story = { - name: '8. Signed out', - beforeEach: bootAsAnonymous, - render: () => , -}; - -/* --------------------------------------------------------------- themes */ - -/** - * Pins its frame to one theme, whatever the toolbar says: the frames copy - * the page's theme class, so this re-applies it if the page changes. - */ -const ThemeLock = ({ theme }: { theme: 'light' | 'dark' }): ReactElement => { - const ref = useRef(null); - useLayoutEffect(() => { - const html = ref.current?.ownerDocument.documentElement; - if (!html) { - return undefined; - } - const apply = () => { - if (html.classList.contains(theme) && html.classList.length === 1) { - return; - } - html.className = theme; - }; - apply(); - const observer = new MutationObserver(apply); - observer.observe(html, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); - }, [theme]); - return
-
- {mounted(t, scene.id) && ( - - )} -
-