From 36ac6829649dd9cf0f494bc8263d2e00a1d87887 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 3 Jun 2026 11:59:11 +0300 Subject: [PATCH 01/28] feat(explore): Medium-style explore topics surface Rebuild the tag/topic discovery surfaces under a new Explore brand, behind the explore_topics_v2 flag (default ON for review): - /explore lobby: hero, topic search, category quick-nav, tagsCategories sections, and the existing trending/popular/recent leaderboards. - /explore/[tag] topic page: related-tags chip bar, centered header with stats (followers when available + stories) and Follow/Block, recommended stories feed, who-to-follow cards, and top sources. - /tags and /tags/[tag] render the new design when the flag is on, legacy otherwise; /explore* redirects to /tags* when the flag is off. - Feed nav repoints Tags -> Explore; explore header gains a Topics sub-tab. - Shared getStaticProps + JSON-LD helper reused by both topic routes. Follower count is an optional Keyword field; it renders only when the backend exposes it (tracked dependency). Co-Authored-By: Claude Opus 4.8 --- .../explore/ExploreCategorySection.tsx | 76 +++ .../components/explore/ExploreTopicPage.tsx | 591 ++++++++++++++++++ .../components/explore/ExploreTopicSearch.tsx | 117 ++++ .../explore/ExploreTopicsPage.spec.tsx | 66 ++ .../components/explore/ExploreTopicsPage.tsx | 138 ++++ .../shared/src/components/feeds/FeedNav.tsx | 9 +- .../components/feeds/UnifiedMobileFeedNav.tsx | 24 +- .../components/header/FeedExploreHeader.tsx | 30 +- .../shared/src/components/tags/TagChip.tsx | 12 +- packages/shared/src/graphql/feedSettings.ts | 13 + packages/shared/src/graphql/keywords.ts | 4 + packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/src/lib/links.ts | 3 + packages/webapp/__tests__/TagPage.tsx | 9 +- packages/webapp/lib/topicPage.ts | 185 ++++++ packages/webapp/pages/explore/[tag].tsx | 90 +-- packages/webapp/pages/explore/index.tsx | 164 +++++ packages/webapp/pages/tags/[tag].tsx | 217 +------ packages/webapp/pages/tags/index.tsx | 31 +- 19 files changed, 1539 insertions(+), 244 deletions(-) create mode 100644 packages/shared/src/components/explore/ExploreCategorySection.tsx create mode 100644 packages/shared/src/components/explore/ExploreTopicPage.tsx create mode 100644 packages/shared/src/components/explore/ExploreTopicSearch.tsx create mode 100644 packages/shared/src/components/explore/ExploreTopicsPage.spec.tsx create mode 100644 packages/shared/src/components/explore/ExploreTopicsPage.tsx create mode 100644 packages/webapp/lib/topicPage.ts create mode 100644 packages/webapp/pages/explore/index.tsx diff --git a/packages/shared/src/components/explore/ExploreCategorySection.tsx b/packages/shared/src/components/explore/ExploreCategorySection.tsx new file mode 100644 index 00000000000..126d29af954 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreCategorySection.tsx @@ -0,0 +1,76 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { TagCategory } from '../../graphql/feedSettings'; +import { TagChip } from '../tags/TagChip'; +import { getExploreTagPageLink } from '../../lib/links'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { ClickableText } from '../buttons/ClickableText'; + +const COLLAPSED_COUNT = 12; + +interface ExploreCategorySectionProps { + category: TagCategory; + followedTags: Set; + className?: string; +} + +export function ExploreCategorySection({ + category, + followedTags, + className, +}: ExploreCategorySectionProps): ReactElement | null { + const [expanded, setExpanded] = useState(false); + + if (!category.tags?.length) { + return null; + } + + const visibleTags = expanded + ? category.tags + : category.tags.slice(0, COLLAPSED_COUNT); + const hasMore = category.tags.length > COLLAPSED_COUNT; + + return ( +
+ + {category.emoji ? `${category.emoji} ` : ''} + {category.title} + +
+ {visibleTags.map((tag) => ( + + ))} +
+ {hasMore && ( + setExpanded((prev) => !prev)} + className="w-fit" + > + {expanded ? 'Show less' : `Show all ${category.tags.length}`} + + )} +
+ ); +} diff --git a/packages/shared/src/components/explore/ExploreTopicPage.tsx b/packages/shared/src/components/explore/ExploreTopicPage.tsx new file mode 100644 index 00000000000..67c1f2c1389 --- /dev/null +++ b/packages/shared/src/components/explore/ExploreTopicPage.tsx @@ -0,0 +1,591 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { useQuery } from '@tanstack/react-query'; +import Head from 'next/head'; +import Feed from '../Feed'; +import { + MOST_DISCUSSED_FEED_QUERY, + MOST_UPVOTED_FEED_QUERY, + TAG_FEED_QUERY, +} from '../../graphql/feed'; +import type { TopPost } from '../../graphql/feed'; +import AuthContext from '../../contexts/AuthContext'; +import type { ButtonProps } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { PageInfoHeader } from '../utilities'; +import useTagAndSource from '../../hooks/useTagAndSource'; +import { AuthTriggers } from '../../lib/auth'; +import { OtherFeedPage, RequestKey, StaleTime } from '../../lib/query'; +import { LogEvent, Origin } from '../../lib/log'; +import type { Keyword } from '../../graphql/keywords'; +import { IconSize } from '../Icon'; +import { + BlockIcon, + DiscussIcon, + MiniCloseIcon as XIcon, + OpenLinkIcon, + PlusIcon, + UpvoteIcon, +} from '../icons'; +import type { TagsData } from '../../graphql/feedSettings'; +import useFeedSettings from '../../hooks/useFeedSettings'; +import { ReferralCampaignKey, useFeedLayout } from '../../hooks'; +import type { Source } from '../../graphql/sources'; +import { SOURCES_BY_TAG_QUERY } from '../../graphql/sources'; +import type { Connection } from '../../graphql/common'; +import { gqlClient } from '../../graphql/common'; +import { ActiveFeedNameContext } from '../../contexts'; +import HorizontalFeed from '../feeds/HorizontalFeed'; +import { PostType } from '../../graphql/posts'; +import { useFeature } from '../GrowthBookProvider'; +import { feature } from '../../lib/featureManagement'; +import { cloudinarySourceRoadmap } from '../../lib/image'; +import { anchorDefaultRel } from '../../lib/strings'; +import Link from '../utilities/Link'; +import CustomFeedOptionsMenu from '../CustomFeedOptionsMenu'; +import { ArchiveEntryCard } from '../archive/ArchiveEntryCard'; +import { ArchiveBreadcrumbs } from '../archive/ArchiveBreadcrumbs'; +import { ArchiveScopeType } from '../../graphql/archive'; +import { useContentPreference } from '../../hooks/contentPreference/useContentPreference'; +import { ContentPreferenceType } from '../../graphql/contentPreference'; +import { TOP_CREATORS_BY_TAG_QUERY } from '../../graphql/users'; +import type { UserShortProfile } from '../../lib/user'; +import { SponsoredTagHero } from '../brand/SponsoredTagHero'; +import { RelatedEntities } from '../RelatedEntities'; +import UserEntityCard from '../cards/entity/UserEntityCard'; +import { largeNumberFormat } from '../../lib'; +import { getExploreTagPageLink, getTagPageLink } from '../../lib/links'; +import { webappUrl } from '../../lib/constants'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +const SUPPORTED_TYPES = [ + PostType.Article, + PostType.VideoYouTube, + PostType.Collection, + PostType.Share, + PostType.Freeform, + PostType.LiveRoom, +]; + +export interface ExploreTopicPageProps { + tag: string; + initialData: Keyword | null; + topPosts: TopPost[]; + recommendedTags: TagsData['tags']; + topContributors: UserShortProfile[]; + jsonLd?: string | null; +} + +const RelatedTagsBar = ({ + tag, + tags, +}: { + tag: string; + tags: TagsData['tags']; +}): ReactElement | null => { + const names = (tags ?? []) + .map((item) => item.name) + .filter((name): name is string => !!name); + + if (names.length === 0) { + return null; + } + + return ( +
+
+ + + + {names.map((name) => ( + + + + ))} +
+
+
+ ); +}; + +const TagTopSources = ({ tag }: { tag: string }): ReactElement | null => { + const { data: topSources, isPending } = useQuery({ + queryKey: [RequestKey.SourceByTag, null, tag], + queryFn: async () => + gqlClient.request<{ sourcesByTag: Connection }>( + SOURCES_BY_TAG_QUERY, + { tag, first: 6 }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const sources = topSources?.sourcesByTag?.edges?.map((edge) => edge.node); + if (!sources || sources.length === 0) { + return null; + } + + return ( + ({ + id: source.id, + image: source.image, + imageAlt: `${source.name} logo`, + name: source.name, + permalink: source.permalink, + }))} + title="πŸ”” Top sources covering it" + className="mx-4" + /> + ); +}; + +const WhoToFollow = ({ + tag, + initialUsers = [], +}: { + tag: string; + initialUsers?: UserShortProfile[]; +}): ReactElement | null => { + const { data: topContributors, isPending } = useQuery({ + queryKey: [RequestKey.TopCreatorsByTag, null, tag], + queryFn: async () => + gqlClient.request<{ topCreatorsByTag: UserShortProfile[] }>( + TOP_CREATORS_BY_TAG_QUERY, + { tag, limit: 6 }, + ), + enabled: !!tag, + staleTime: StaleTime.OneHour, + }); + + const users = topContributors?.topCreatorsByTag ?? initialUsers; + + if (isPending && initialUsers.length === 0) { + return null; + } + + if (!users || users.length === 0) { + return null; + } + + return ( +
+ + Who to follow + +
+ {users.map((user) => ( + + ))} +
+
+ ); +}; + +export const ExploreTopicPage = ({ + tag, + initialData, + topPosts, + recommendedTags, + topContributors, + jsonLd, +}: ExploreTopicPageProps): ReactElement => { + const { push } = useRouter(); + const showRoadmap = useFeature(feature.showRoadmap); + const { user, showLogin } = useContext(AuthContext); + const { feedSettings } = useFeedSettings(); + const { FeedPageLayoutComponent } = useFeedLayout(); + const { onFollowTags, onUnfollowTags, onBlockTags, onUnblockTags } = + useTagAndSource({ origin: Origin.TagPage }); + const { follow, unfollow } = useContentPreference({ + showToastOnSuccess: false, + }); + + const title = initialData?.flags?.title || tag; + const followers = initialData?.followers; + const occurrences = initialData?.occurrences ?? 0; + + const topPostsQueryVariables = useMemo( + () => ({ tag, ranking: 'POPULARITY', supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + const mostUpvotedQueryVariables = useMemo( + () => ({ tag, supportedTypes: SUPPORTED_TYPES, period: 365 }), + [tag], + ); + const bestDiscussedQueryVariables = useMemo( + () => ({ tag, period: 365, supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + const mainFeedQueryVariables = useMemo( + () => ({ tag, ranking: 'TIME', supportedTypes: SUPPORTED_TYPES }), + [tag], + ); + + const tagStatus = useMemo(() => { + if (!feedSettings) { + return 'unfollowed'; + } + if ((feedSettings.blockedTags ?? []).includes(tag)) { + return 'blocked'; + } + if ((feedSettings.includeTags ?? []).includes(tag)) { + return 'followed'; + } + return 'unfollowed'; + }, [feedSettings, tag]); + + const followButtonProps: ButtonProps<'button'> = { + size: ButtonSize.Small, + icon: tagStatus === 'followed' ? : , + onClick: async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + if (tagStatus === 'followed') { + await onUnfollowTags({ tags: [tag] }); + } else { + await onFollowTags({ tags: [tag] }); + } + }, + }; + + const blockButtonProps: ButtonProps<'button'> = { + size: ButtonSize.Small, + icon: tagStatus === 'blocked' ? : , + onClick: async (): Promise => { + if (!user) { + showLogin({ trigger: AuthTriggers.Filter }); + return; + } + if (tagStatus === 'blocked') { + await onUnblockTags({ tags: [tag] }); + } else { + await onBlockTags({ tags: [tag] }); + } + }, + }; + + const statParts: ReactNode[] = []; + if (typeof followers === 'number') { + statParts.push( + {largeNumberFormat(followers)} followers, + ); + } + statParts.push( + {largeNumberFormat(occurrences)} stories, + ); + + return ( + + {jsonLd && ( + +