From 3855dc68895a67dc9bdaf3d1f4c33df9d4ebca02 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 09:30:06 +0200 Subject: [PATCH 1/9] feat: custom feeds chips --- .../src/components/CustomFeedEmptyScreen.tsx | 107 ++++---------- .../shared/src/components/MainFeedLayout.tsx | 35 +++-- .../src/components/feeds/ExploreChipsBar.tsx | 12 ++ .../feeds/FeedSettings/FeedSettingsEdit.tsx | 17 ++- .../FeedSettings/FeedSettingsEditHeader.tsx | 25 +++- .../FeedSettings/FeedSettingsPlusGate.tsx | 50 +++++++ .../sections/FeedSettingsGeneralSection.tsx | 136 +++++++++--------- .../FeedSettings/useFeedSettingsEdit.tsx | 23 ++- .../components/feeds/FeedSettingsButton.tsx | 62 ++++---- .../src/components/feeds/NewStripCta.tsx | 40 ++++++ .../components/feeds/UnifiedMobileFeedNav.tsx | 22 +-- .../src/components/feeds/exploreCategories.ts | 16 ++- .../sidebar/sections/CustomFeedSection.tsx | 65 +++++---- packages/shared/src/contexts/AuthContext.tsx | 5 + packages/shared/src/contexts/BootProvider.tsx | 42 ++---- packages/shared/src/graphql/actions.ts | 1 + packages/shared/src/graphql/feed.ts | 9 +- packages/shared/src/graphql/feedTagsList.ts | 23 --- packages/shared/src/graphql/fragments.ts | 1 + packages/shared/src/hooks/feed/useFeeds.ts | 27 +++- packages/shared/src/hooks/useFeedTagsList.ts | 38 ----- packages/shared/src/lib/constants.ts | 1 + packages/shared/src/lib/query.ts | 1 - .../webapp/pages/feeds/[slugOrId]/edit.tsx | 20 ++- .../webapp/pages/feeds/[slugOrId]/index.tsx | 7 +- 25 files changed, 425 insertions(+), 360 deletions(-) create mode 100644 packages/shared/src/components/feeds/FeedSettings/FeedSettingsPlusGate.tsx create mode 100644 packages/shared/src/components/feeds/NewStripCta.tsx delete mode 100644 packages/shared/src/graphql/feedTagsList.ts delete mode 100644 packages/shared/src/hooks/useFeedTagsList.ts diff --git a/packages/shared/src/components/CustomFeedEmptyScreen.tsx b/packages/shared/src/components/CustomFeedEmptyScreen.tsx index 4454b51e8e9..f4afbab797d 100644 --- a/packages/shared/src/components/CustomFeedEmptyScreen.tsx +++ b/packages/shared/src/components/CustomFeedEmptyScreen.tsx @@ -1,10 +1,8 @@ -import type { Dispatch, ReactElement, SetStateAction } from 'react'; +import type { Dispatch, ReactElement, ReactNode, SetStateAction } from 'react'; import React from 'react'; import { EmptyScreenIcon } from './EmptyScreen'; -import { DevPlusIcon, HashtagIcon } from './icons'; +import { HashtagIcon } from './icons'; import { PageContainer, SharedFeedPage } from './utilities'; -import { ButtonSize, ButtonVariant } from './buttons/common'; -import { plusUrl } from '../lib/constants'; import { DEFAULT_ALGORITHM_INDEX, DEFAULT_ALGORITHM_KEY, @@ -14,23 +12,16 @@ import usePersistentContext from '../hooks/usePersistentContext'; import { Typography, TypographyColor, - TypographyTag, TypographyType, } from './typography/Typography'; -import { LogEvent, TargetId } from '../lib/log'; -import { Button } from './buttons/Button'; -import { useConditionalFeature, usePlusSubscription } from '../hooks'; -import { IconSize } from './Icon'; -import { featurePlusApiLanding } from '../lib/featureManagement'; -import Link from './utilities/Link'; -export const CustomFeedEmptyScreen = (): ReactElement => { - const { logSubscriptionEvent, isPlus } = usePlusSubscription(); - const { value: isApiLanding } = useConditionalFeature({ - feature: featurePlusApiLanding, - shouldEvaluate: !isPlus, - }); - const plusCta = isApiLanding ? 'Get API Access' : 'Level Up with Plus'; +type CustomFeedEmptyScreenProps = { + chips?: ReactNode; +}; + +export const CustomFeedEmptyScreen = ({ + chips, +}: CustomFeedEmptyScreenProps = {}): ReactElement => { const [selectedAlgo, setSelectedAlgo] = usePersistentContext( DEFAULT_ALGORITHM_KEY, DEFAULT_ALGORITHM_INDEX, @@ -48,10 +39,11 @@ export const CustomFeedEmptyScreen = (): ReactElement => { return (
-
+
@@ -60,69 +52,20 @@ export const CustomFeedEmptyScreen = (): ReactElement => { className={EmptyScreenIcon.className} style={EmptyScreenIcon.style} /> - {!isPlus ? ( - <> - - Plus - - - Custom feeds got a massive upgrade! - - - {`Custom Feeds is now more powerful than ever before, with - advanced filters, extensive customization options, and complete - feed control. Upgrade to Plus to unlock this ultimate tool for - tailoring your content.`} - - - - - - ) : ( - <> - - Your feed filters are too specific. - - - We couldn't fetch enough posts based on your selected tags. - Try adding more tags using the feed settings. - - - )} + + Your feed filters are too specific. + + + We couldn't fetch enough posts based on your selected tags. Try + adding more tags using the feed settings. +
diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 767a8a16c83..ecb2e61f712 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode, SetStateAction } from 'react'; import React, { + cloneElement, useCallback, useContext, useEffect, @@ -14,7 +15,7 @@ import Feed from './Feed'; import { FeedPageLayoutMobile } from './utilities/common'; import { ExploreChipsBar } from './feeds/ExploreChipsBar'; import { buildPersonalizedCategories } from './feeds/exploreCategories'; -import { useFeedTagsList } from '../hooks/useFeedTagsList'; +import { useFeeds } from '../hooks/feed/useFeeds'; import ReadingReminderHero from './marketing/banners/ReadingReminderHero'; import { WebappShortcutsRow } from '../features/shortcuts/components/WebappShortcutsRow'; import { LiveStandupsStrip } from './liveRooms/LiveStandupsStrip'; @@ -323,29 +324,27 @@ export default function MainFeedLayout({ }); const isChipStripPage = - router.pathname === '/' || router.pathname === '/explore/[tag]'; + router.pathname === '/' || + router.pathname === '/explore/[tag]' || + router.pathname === '/feeds/[slugOrId]' || + router.pathname === '/feeds/[slugOrId]/edit'; const { value: isFeedTagChipsEnabled } = useConditionalFeature({ feature: featureFeedTagChips, shouldEvaluate: !!user && isLaptop && isChipStripPage, }); const showExploreChips = !!user && isLaptop && isChipStripPage && isFeedTagChipsEnabled; - const { tags: feedTags, isPending: isFeedTagsPending } = useFeedTagsList({ - enabled: showExploreChips, - }); + const { feeds } = useFeeds(); const exploreCategories = useMemo( - () => buildPersonalizedCategories(feedTags), - [feedTags], + () => buildPersonalizedCategories(feeds?.edges ?? []), + [feeds?.edges], ); const chipsNode = useMemo( () => showExploreChips ? ( - + ) : null, - [showExploreChips, exploreCategories, isFeedTagsPending], + [showExploreChips, exploreCategories, feeds], ); const { isSearchPageLaptop } = useSearchResultsLayout(); @@ -505,6 +504,14 @@ export default function MainFeedLayout({ return null; } + const baseEmptyScreen = propsByFeed[feedName]?.emptyScreen || ( + + ); + const emptyScreenWithChips = cloneElement( + baseEmptyScreen as ReactElement<{ chips?: ReactNode }>, + { chips: chipsNode }, + ); + if (feedNameProp === 'default' && isCustomDefaultFeed) { if (!defaultFeedId) { return null; @@ -522,7 +529,7 @@ export default function MainFeedLayout({ feedId: defaultFeedId, feedName: SharedFeedPage.Custom, }, - emptyScreen: propsByFeed[feedName]?.emptyScreen || , + emptyScreen: emptyScreenWithChips, actionButtons: feedWithActions && ( , + emptyScreen: emptyScreenWithChips, actionButtons: feedWithActions && ( + {allCategories.map((category) => { // For You owns the homepage. Match it against both `/` and `/my-feed` // so the user's default custom feed (also at `/`) doesn't steal the @@ -121,6 +124,15 @@ export function ExploreChipsBar({ className={classNames('h-10 shrink-0 rounded-12', width)} /> ))} + + + + +
- + + + - + + + - + + + - + + + diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx index 187a8a29ccf..1f7ddd655a5 100644 --- a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx @@ -7,14 +7,17 @@ import { ButtonSize, ButtonVariant } from '../../buttons/common'; import { Modal } from '../../modals/common/Modal'; import { ModalPropsContext } from '../../modals/common/types'; import { FeedSettingsTitle } from './FeedSettingsTitle'; +import type { PromptOptions } from '../../../hooks/usePrompt'; +import { usePrompt } from '../../../hooks/usePrompt'; +import { labels } from '../../../lib/labels'; import { useConditionalFeature, usePlusSubscription } from '../../../hooks'; import { DevPlusIcon } from '../../icons'; import { LogEvent, TargetId } from '../../../lib/log'; import { FeedType } from '../../../graphql/feed'; -import type { PromptOptions } from '../../../hooks/usePrompt'; -import { usePrompt } from '../../../hooks/usePrompt'; -import { labels } from '../../../lib/labels'; -import { featurePlusCtaCopy } from '../../../lib/featureManagement'; +import { + featureFeedTagChips, + featurePlusCtaCopy, +} from '../../../lib/featureManagement'; const createGenericFeedPrompt: PromptOptions = { title: labels.feed.prompt.createGenericFeed.title, @@ -92,13 +95,23 @@ export const FeedSettingsEditHeader = (): ReactElement => { const { activeView, setActiveView } = useContext(ModalPropsContext); const isMobile = useViewSizeClient(ViewSize.MobileL); const { isPlus, logSubscriptionEvent } = usePlusSubscription(); + const { value: isFeedTagChipsEnabled } = useConditionalFeature({ + feature: featureFeedTagChips, + shouldEvaluate: !isPlus, + }); const { value: { full: plusCta }, } = useConditionalFeature({ feature: featurePlusCtaCopy, - shouldEvaluate: !isPlus, + shouldEvaluate: !isPlus && !isFeedTagChipsEnabled, }); + // Pre-chips behavior: non-Plus on Custom feeds saw a forced Plus CTA in + // place of Save. Once the chips feature is on, free users get a working + // SaveButton (advanced sections still self-upsell via FeedSettingsPlusGate). + const showPlusCta = + !isFeedTagChipsEnabled && !isPlus && feed?.type === FeedType.Custom; + if (!activeView) { return null; } @@ -131,7 +144,7 @@ export const FeedSettingsEditHeader = (): ReactElement => { > Cancel - {!isPlus && feed?.type === FeedType.Custom ? ( + {showPlusCta ? ( + )} + {isMainFeed && ( + +
+ +
+
+ )}
- {isCustomFeed && ( - - )} - {isMainFeed && ( - -
- -
-
- )} - + )}
diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx index 0adeddee495..d5c368f1186 100644 --- a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx @@ -15,6 +15,7 @@ import { REMOVE_FILTERS_FROM_FEED_MUTATION, } from '../../../graphql/feedSettings'; import { + useConditionalFeature, useEventListener, useFeeds, usePlusSubscription, @@ -22,6 +23,7 @@ import { useViewSizeClient, ViewSize, } from '../../../hooks'; +import { featureFeedTagChips } from '../../../lib/featureManagement'; import { useExitConfirmation } from '../../../hooks/useExitConfirmation'; import type { PromptOptions } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt'; @@ -56,6 +58,10 @@ export const useFeedSettingsEdit = ({ isNewFeed, }: UseFeedSettingsEditProps): UseFeedSettingsEdit => { const { isPlus } = usePlusSubscription(); + const { value: isFeedTagChipsEnabled } = useConditionalFeature({ + feature: featureFeedTagChips, + shouldEvaluate: !isPlus, + }); const isMobile = useViewSizeClient(ViewSize.MobileL); const discardNewPrompt: PromptOptions = { @@ -130,10 +136,10 @@ export const useFeedSettingsEdit = ({ const onBackToFeed = useCallback( async ({ action }: { action?: 'discard' | 'save' }) => { - if (action === 'save' && isNewFeed && !isPlus) { - // for non plus members on confirm we save - // and navigate to plus page to upgrade - + if (action === 'save' && isNewFeed && !isPlus && !isFeedTagChipsEnabled) { + // Pre-chips behavior: non-Plus saving a new feed gets redirected to + // the Plus upgrade page. Once the chips feature is on, free users + // own their feeds and land on the new feed instead. router.replace(plusUrl); return; @@ -171,6 +177,7 @@ export const useFeedSettingsEdit = ({ deleteFeed, feedId, isPlus, + isFeedTagChipsEnabled, ], ); @@ -185,7 +192,13 @@ export const useFeedSettingsEdit = ({ const { mutateAsync: onSubmit, isPending: isSubmitPending } = useMutation({ mutationFn: async () => { - const result = await updateFeed({ ...feedData, feedId }); + // Free users can only edit name + icon; advanced flags are Plus-only and + // rejected server-side. Scope the payload so saving name/icon never trips + // that guard on feeds that already carry advanced flags. + const updatePayload = isPlus + ? { ...feedData, feedId } + : { feedId, name: feedData.name, icon: feedData.icon }; + const result = await updateFeed(updatePayload); const tagPromises = [ gqlClient.request(ADD_FILTERS_TO_FEED_MUTATION, { feedId: result.id, diff --git a/packages/shared/src/components/feeds/FeedSettingsButton.tsx b/packages/shared/src/components/feeds/FeedSettingsButton.tsx index 62709bb21ff..4120b270516 100644 --- a/packages/shared/src/components/feeds/FeedSettingsButton.tsx +++ b/packages/shared/src/components/feeds/FeedSettingsButton.tsx @@ -16,7 +16,10 @@ import { usePrompt } from '../../hooks/usePrompt'; import { plusUrl, webappUrl } from '../../lib/constants'; import { FeedType } from '../../graphql/feed'; import { labels } from '../../lib/labels'; -import { featurePlusCtaCopy } from '../../lib/featureManagement'; +import { + featureFeedTagChips, + featurePlusCtaCopy, +} from '../../lib/featureManagement'; const editPlusSubscribePrompt: PromptOptions = { title: labels.feed.prompt.editPlusSubscribe.title, @@ -37,6 +40,10 @@ export function FeedSettingsButton({ }: ButtonProps<'button'>): ReactElement { const { logEvent } = useLogContext(); const { isPlus } = usePlusSubscription(); + const { value: isFeedTagChipsEnabled } = useConditionalFeature({ + feature: featureFeedTagChips, + shouldEvaluate: !isPlus, + }); const { feeds, deleteFeed } = useFeeds(); const router = useRouter(); const { showPrompt } = usePrompt(); @@ -44,42 +51,47 @@ export function FeedSettingsButton({ value: { full: plusCta }, } = useConditionalFeature({ feature: featurePlusCtaCopy, - shouldEvaluate: !isPlus, + shouldEvaluate: !isPlus && !isFeedTagChipsEnabled, }); const onButtonClick = async (event: React.MouseEvent) => { logEvent({ event_name: LogEvent.ManageTags }); - const feedSlugOrId = router?.query?.slugOrId; + // Pre-chips behavior: non-Plus on a Custom feed is prompted to upgrade, + // and on decline the feed is deleted. Once the chips feature is on, free + // users open the editor and `FeedSettingsPlusGate` upsells advanced sections. + if (!isFeedTagChipsEnabled && !isPlus) { + const feedSlugOrId = router?.query?.slugOrId; - const feed = feeds?.edges.find( - (item) => - item.node.id === feedSlugOrId || item.node.slug === feedSlugOrId, - ); + const feed = feeds?.edges.find( + (item) => + item.node.id === feedSlugOrId || item.node.slug === feedSlugOrId, + ); - if (!isPlus && feed?.node.type === FeedType.Custom) { - const subscribeToPlus = await showPrompt({ - ...editPlusSubscribePrompt, - description: editPlusSubscribePrompt.description, - okButton: { - ...editPlusSubscribePrompt.okButton, - title: plusCta, - }, - }); + if (feed?.node.type === FeedType.Custom) { + const subscribeToPlus = await showPrompt({ + ...editPlusSubscribePrompt, + description: editPlusSubscribePrompt.description, + okButton: { + ...editPlusSubscribePrompt.okButton, + title: plusCta, + }, + }); - if (subscribeToPlus) { - router?.push(plusUrl); + if (subscribeToPlus) { + router?.push(plusUrl); - return; - } + return; + } - deleteFeed({ - feedId: feed.node.id, - }); + deleteFeed({ + feedId: feed.node.id, + }); - router?.replace(webappUrl); + router?.replace(webappUrl); - return; + return; + } } onClick?.(event); diff --git a/packages/shared/src/components/feeds/NewStripCta.tsx b/packages/shared/src/components/feeds/NewStripCta.tsx new file mode 100644 index 00000000000..006b540cad6 --- /dev/null +++ b/packages/shared/src/components/feeds/NewStripCta.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Tooltip } from '../tooltip/Tooltip'; +import { chipsDocs } from '../../lib/constants'; +import { useActions } from '../../hooks/useActions'; +import { ActionType } from '../../graphql/actions'; + +type NewStripCtaProps = { + className?: string; +}; + +export const NewStripCta = ({ + className, +}: NewStripCtaProps): ReactElement | null => { + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + + if (!isActionsFetched || checkHasCompleted(ActionType.ClickedNewStripCta)) { + return null; + } + + return ( + + { + completeAction(ActionType.ClickedNewStripCta); + }} + className={classNames( + 'shadow-md inline-flex shrink-0 items-center bg-gradient-to-r from-accent-cabbage-default to-accent-onion-default font-bold text-white typo-callout', + className, + )} + > + Want chips? + + + ); +}; diff --git a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx index 52b4e94ebf2..352fad7f4df 100644 --- a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx +++ b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx @@ -8,11 +8,10 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { useFeeds } from '../../hooks'; import { useSortedFeeds } from '../../hooks/feed/useSortedFeeds'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; -import { useFeedTagsList } from '../../hooks/useFeedTagsList'; import { webappUrl } from '../../lib/constants'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent } from '../../lib/log'; -import { buildPersonalizedCategories } from './exploreCategories'; +import { NewStripCta } from './NewStripCta'; type ChipGroup = 'forYou' | 'categories' | 'rest'; @@ -41,7 +40,6 @@ function UnifiedMobileFeedNav(): ReactElement { const { feeds } = useFeeds(); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const sortedFeeds = useSortedFeeds({ edges: feeds?.edges }); - const { tags: personalizedTags } = useFeedTagsList(); const { logEvent } = useLogContext(); const items: ChipItem[] = useMemo(() => { @@ -58,18 +56,6 @@ function UnifiedMobileFeedNav(): ReactElement { group: 'forYou', }); - if (isLoggedIn) { - buildPersonalizedCategories(personalizedTags).forEach((category) => { - list.push({ - id: `category-${category.id}`, - label: category.label, - href: category.path, - group: 'categories', - tag: category.tag, - }); - }); - } - sortedFeeds.forEach(({ node: feed }) => { const isEditingFeed = router.query.slugOrId === feed.id && router.pathname.endsWith('/edit'); @@ -82,7 +68,7 @@ function UnifiedMobileFeedNav(): ReactElement { id: `feed-${feed.id}`, label: feed.flags?.name || `Feed ${feed.id}`, href, - group: 'rest', + group: 'categories', }); }); if (isLoggedIn) { @@ -94,7 +80,7 @@ function UnifiedMobileFeedNav(): ReactElement { ), href: `${webappUrl}feeds/new`, - group: 'rest', + group: 'categories', isIconOnly: true, }); } @@ -190,7 +176,6 @@ function UnifiedMobileFeedNav(): ReactElement { router.query.slugOrId, router.pathname, defaultFeedId, - personalizedTags, ]); const activeId = useMemo(() => { @@ -231,6 +216,7 @@ function UnifiedMobileFeedNav(): ReactElement { ref={scrollRef} className="no-scrollbar flex w-full items-center gap-2 overflow-x-auto border-b border-border-subtlest-tertiary bg-background-default px-3 py-4" > + {GROUP_ORDER.map((group) => { const groupItems = items.filter((item) => item.group === group); if (!groupItems.length) { diff --git a/packages/shared/src/components/feeds/exploreCategories.ts b/packages/shared/src/components/feeds/exploreCategories.ts index db20a20d2b5..2b654ffbd00 100644 --- a/packages/shared/src/components/feeds/exploreCategories.ts +++ b/packages/shared/src/components/feeds/exploreCategories.ts @@ -1,4 +1,5 @@ -import type { FeedTagsListItem } from '../../graphql/feedTagsList'; +import type { Edge } from '../../graphql/common'; +import type { Feed } from '../../graphql/feed'; import { webappUrl } from '../../lib/constants'; export type ExploreCategory = { @@ -8,12 +9,13 @@ export type ExploreCategory = { tag?: string; }; +// Feeds are rendered in the order the API returns them. export const buildPersonalizedCategories = ( - tags: FeedTagsListItem[], + edges: Edge[], ): ExploreCategory[] => - tags.map(({ value, label }) => ({ - id: value, - label, - path: `${webappUrl}explore/${value}`, - tag: value, + edges.map(({ node: feed }) => ({ + id: feed.id, + label: feed.flags?.name || `Feed ${feed.id}`, + path: `${webappUrl}feeds/${feed.slug}`, + tag: feed.id, })); diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx index 1cb8fb5c833..3cdaf193e1a 100644 --- a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx +++ b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx @@ -10,6 +10,7 @@ import type { SidebarSectionProps } from './common'; import useCustomDefaultFeed from '../../../hooks/feed/useCustomDefaultFeed'; import { isExtension } from '../../../lib/func'; import { useSortedFeeds } from '../../../hooks/feed/useSortedFeeds'; +import { FeedOrigin } from '../../../graphql/feed'; export const CustomFeedSection = ({ isItemsButton, @@ -22,43 +23,47 @@ export const CustomFeedSection = ({ const menuItems: SidebarMenuItem[] = useMemo(() => { const customFeeds = - sortedFeeds.map((feed) => { - const isDefaultFeed = defaultFeedId === feed.node.id; + sortedFeeds + .filter((feed) => feed.node.flags?.origin !== FeedOrigin.TagChip) + .map((feed) => { + const isDefaultFeed = defaultFeedId === feed.node.id; - if (isDefaultFeed) { - const isCustomFeedPageActive = [ - `${webappUrl}feeds/${feed.node.id}`, - '/', - ].includes(defaultRenderSectionProps.activePage); + if (isDefaultFeed) { + const isCustomFeedPageActive = [ + `${webappUrl}feeds/${feed.node.id}`, + '/', + ].includes(defaultRenderSectionProps.activePage); + + return { + title: feed.node.flags.name || `Feed ${feed.node.id}`, + // on extension we don't use router so no need for a path + // onNavTabClick takes care of the navigation + path: isExtension ? undefined : '/', + action: isExtension + ? () => onNavTabClick?.('default') + : undefined, + icon: feed.node.flags.icon || ( + + ), + rightIcon: () => ( + + ), + active: isCustomFeedPageActive, + }; + } + + const feedPath = `${webappUrl}feeds/${feed.node.id}`; return { title: feed.node.flags.name || `Feed ${feed.node.id}`, - // on extension we don't use router so no need for a path - // onNavTabClick takes care of the navigation - path: isExtension ? undefined : '/', - action: isExtension ? () => onNavTabClick?.('default') : undefined, + path: feedPath, icon: feed.node.flags.icon || ( - - ), - rightIcon: () => ( - + ), - active: isCustomFeedPageActive, }; - } - - const feedPath = `${webappUrl}feeds/${feed.node.id}`; - - return { - title: feed.node.flags.name || `Feed ${feed.node.id}`, - path: feedPath, - icon: feed.node.flags.icon || ( - - ), - }; - }) ?? []; + }) ?? []; return customFeeds.filter(Boolean); }, [ diff --git a/packages/shared/src/contexts/AuthContext.tsx b/packages/shared/src/contexts/AuthContext.tsx index adeba0120a7..63e820fe691 100644 --- a/packages/shared/src/contexts/AuthContext.tsx +++ b/packages/shared/src/contexts/AuthContext.tsx @@ -17,6 +17,7 @@ import { isCompanionActivated } from '../lib/element'; import type { AuthTriggersType } from '../lib/auth'; import type { AuthDisplay } from '../components/auth/common'; import type { Squad } from '../graphql/sources'; +import type { Feed } from '../graphql/feed'; import { checkIsExtension, isIOSNative, isNullOrUndefined } from '../lib/func'; import { AFTER_AUTH_PARAM } from '../components/auth/common'; import { Continent, outsideGdpr } from '../lib/geo'; @@ -80,6 +81,7 @@ export interface AuthContextData { isGdprCovered?: boolean; isValidRegion?: boolean; isFunnel?: boolean; + feeds?: Feed[]; } const isExtension = checkIsExtension(); @@ -142,6 +144,7 @@ export type AuthContextProviderProps = { | 'refetchBoot' | 'geo' | 'isAndroidApp' + | 'feeds' >; export const AuthContextProvider = ({ @@ -157,6 +160,7 @@ export const AuthContextProvider = ({ visit, accessToken, squads, + feeds, firstLoad, geo, isAndroidApp, @@ -244,6 +248,7 @@ export const AuthContextProvider = ({ deleteAccount, accessToken, squads, + feeds, geo, isAndroidApp, isValidRegion, diff --git a/packages/shared/src/contexts/BootProvider.tsx b/packages/shared/src/contexts/BootProvider.tsx index 328ca874d8a..f31235e6678 100644 --- a/packages/shared/src/contexts/BootProvider.tsx +++ b/packages/shared/src/contexts/BootProvider.tsx @@ -21,7 +21,6 @@ import { BOOT_LOCAL_KEY, BOOT_QUERY_KEY } from './common'; import { GrowthBookProvider } from '../components/GrowthBookProvider'; import { useHostStatus } from '../hooks/useHostPermissionStatus'; import { checkIsExtension, isIOSNative } from '../lib/func'; -import type { Feed, FeedList } from '../graphql/feed'; import type { ApiErrorResult } from '../graphql/common'; import { ApiError, getApiError, gqlClient } from '../graphql/common'; import { ErrorBoundary } from '../components/ErrorBoundary'; @@ -104,14 +103,6 @@ const getCachedOrNull = () => { } }; -export type PreloadFeeds = ({ - feeds, - user, -}: { - feeds?: Feed[]; - user?: Pick; -}) => void; - export const BootDataProvider = ({ children, app, @@ -122,23 +113,6 @@ export const BootDataProvider = ({ getPage, }: BootDataProviderProps): ReactElement => { const queryClient = useQueryClient(); - const preloadFeedsFn: PreloadFeeds = ({ feeds, user }) => { - if (!feeds || !user) { - return; - } - - queryClient.setQueryData( - generateQueryKey(RequestKey.Feeds, user), - { - edges: feeds.map((item) => ({ node: item })), - pageInfo: { - hasNextPage: false, - }, - }, - ); - }; - const preloadFeedsRef = useRef(preloadFeedsFn); - preloadFeedsRef.current = preloadFeedsFn; const [initialLoad, setInitialLoad] = useState(); const [cachedBootData, setCachedBootData] = useState>(); @@ -162,8 +136,6 @@ export const BootDataProvider = ({ applyTheme(themeModes[boot.settings.theme]); } - preloadFeedsRef.current({ feeds: boot.feeds, user: boot.user }); - setCachedBootData(boot); }, [localBootData]); @@ -185,7 +157,6 @@ export const BootDataProvider = ({ queryFn: async () => { const pathname = globalThis?.location?.pathname; const result = await getBootData({ app, pathname }); - preloadFeedsRef.current({ feeds: result.feeds, user: result.user }); return result; }, @@ -196,8 +167,16 @@ export const BootDataProvider = ({ const isBootReady = isFetched && !isError; const loadedFromCache = !!cachedBootData; - const { user, settings, alerts, notifications, squads, geo, isAndroidApp } = - cachedBootData || {}; + const { + user, + settings, + alerts, + notifications, + squads, + feeds, + geo, + isAndroidApp, + } = cachedBootData || {}; useRefreshToken(remoteData?.accessToken, refetch); @@ -380,6 +359,7 @@ export const BootDataProvider = ({ firstLoad={initialLoad} geo={geo} isAndroidApp={isAndroidApp} + feeds={feeds} > { const queryClient = useQueryClient(); const { displayToast } = useToastNotification(); - const { user } = useAuthContext(); + const { user, feeds: bootFeeds } = useAuthContext(); const queryKey = generateQueryKey(RequestKey.Feeds, user); + const { value: includeTagChipFeeds } = useConditionalFeature({ + feature: featureFeedTagChips, + shouldEvaluate: !!user, + }); + + const initialData: FeedList['feedList'] | undefined = useMemo(() => { + if (!bootFeeds) { + return undefined; + } + + return { + edges: bootFeeds.map((node) => ({ node })), + pageInfo: { hasNextPage: false }, + }; + }, [bootFeeds]); + const { data: feeds } = useQuery({ queryKey, queryFn: async () => { - const result = await gqlClient.request(FEED_LIST_QUERY); + const result = await gqlClient.request(FEED_LIST_QUERY, { + includeTagChipFeeds, + }); return result.feedList; }, enabled: !!user, + initialData, + initialDataUpdatedAt: 0, // to interim force re-fetch until we sunset boot feeds data staleTime: StaleTime.OneHour, }); diff --git a/packages/shared/src/hooks/useFeedTagsList.ts b/packages/shared/src/hooks/useFeedTagsList.ts deleted file mode 100644 index a57feeb5f13..00000000000 --- a/packages/shared/src/hooks/useFeedTagsList.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useAuthContext } from '../contexts/AuthContext'; -import { - FEED_TAGS_LIST_QUERY, - type FeedTagsListData, -} from '../graphql/feedTagsList'; -import { RequestKey, StaleTime, generateQueryKey } from '../lib/query'; -import { useRequestProtocol } from './useRequestProtocol'; - -const DEFAULT_LIMIT = 15; - -export const useFeedTagsList = ({ - limit = DEFAULT_LIMIT, - enabled = true, -}: { limit?: number; enabled?: boolean } = {}) => { - const { isLoggedIn, user } = useAuthContext(); - const { requestMethod } = useRequestProtocol(); - - const query = useQuery({ - queryKey: generateQueryKey(RequestKey.FeedTagsList, user, limit), - queryFn: async () => { - const result = await requestMethod( - FEED_TAGS_LIST_QUERY, - { limit }, - ); - return result.feedTagsList; - }, - enabled: isLoggedIn && enabled, - staleTime: StaleTime.OneHour, - gcTime: StaleTime.OneDay, - retry: false, - }); - - return { - ...query, - tags: query.data?.tags ?? [], - }; -}; diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index c7907deefda..f4e013554f0 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -23,6 +23,7 @@ export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; export const docs = 'https://r.daily.dev/docs'; export const plusPublicApiDocs = 'https://docs.daily.dev/docs/plus/public-api'; +export const chipsDocs = 'https://docs.daily.dev/chips/'; export const markdownGuide = 'https://r.daily.dev/markdown-guide'; export const careers = 'https://r.daily.dev/careers'; export const firstNotificationLink = 'https://r.daily.dev/notifications'; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 8c04ec4a659..cedc60caae5 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -177,7 +177,6 @@ export enum RequestKey { PublicSquadRequests = 'public_squad_requests', Feeds = 'feeds', FeedSettings = 'feedSettings', - FeedTagsList = 'feedTagsList', Ads = 'ads', FeedByIds = 'feedByIds', SlackChannels = 'slack_channels', diff --git a/packages/webapp/pages/feeds/[slugOrId]/edit.tsx b/packages/webapp/pages/feeds/[slugOrId]/edit.tsx index 2abe1bf8a6e..b661d1e3ad0 100644 --- a/packages/webapp/pages/feeds/[slugOrId]/edit.tsx +++ b/packages/webapp/pages/feeds/[slugOrId]/edit.tsx @@ -4,10 +4,15 @@ import type { NextSeoProps } from 'next-seo'; import { useFeedLayout } from '@dailydotdev/shared/src/hooks/useFeedLayout'; import { FeedSettingsEdit } from '@dailydotdev/shared/src/components/feeds/FeedSettings/FeedSettingsEdit'; import { useRouter } from 'next/router'; - -import { useFeeds, usePlusSubscription } from '@dailydotdev/shared/src/hooks'; +import { + useConditionalFeature, + useFeeds, + usePlusSubscription, +} from '@dailydotdev/shared/src/hooks'; import { FeedType } from '@dailydotdev/shared/src/graphql/feed'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { featureFeedTagChips } from '@dailydotdev/shared/src/lib/featureManagement'; + import { getMainFeedLayout, mainFeedLayoutProps, @@ -27,12 +32,21 @@ const EditFeedPage = (): ReactElement | null => { const { FeedPageLayoutComponent } = useFeedLayout(); const feedSlugOrId = router.query.slugOrId as string; const { isPlus } = usePlusSubscription(); + const { value: isFeedTagChipsEnabled } = useConditionalFeature({ + feature: featureFeedTagChips, + shouldEvaluate: !isPlus, + }); const { feeds } = useFeeds(); const feed = feeds?.edges.find( (item) => item.node.id === feedSlugOrId || item.node.slug === feedSlugOrId, ); - const isFeedEditRestricted = !isPlus && feed?.node.type === FeedType.Custom; + // Pre-chips behavior: non-Plus cannot edit custom feeds. Once the chips + // feature is on, free users own their custom feeds and the editor opens. + const isFeedEditRestricted = + !isFeedTagChipsEnabled && + !isPlus && + feed?.node.type === FeedType.Custom; useEffect(() => { document.body.classList.add('hidden-scrollbar'); diff --git a/packages/webapp/pages/feeds/[slugOrId]/index.tsx b/packages/webapp/pages/feeds/[slugOrId]/index.tsx index e1e09002f2c..6f4a2e8ed16 100644 --- a/packages/webapp/pages/feeds/[slugOrId]/index.tsx +++ b/packages/webapp/pages/feeds/[slugOrId]/index.tsx @@ -17,8 +17,11 @@ const FeedPage = (): ReactElement => { const { feeds } = useFeeds(); const feed = useMemo(() => { - return feeds?.edges.find(({ node }) => node.id === router.query.slugOrId) - ?.node; + return feeds?.edges.find( + ({ node }) => + node.id === router.query.slugOrId || + node.slug === router.query.slugOrId, + )?.node; }, [feeds, router.query.slugOrId]); useEffect(() => { From 41af958f7011a9d51ca47018382b92c433c7edf8 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 10:21:05 +0200 Subject: [PATCH 2/9] fix: lint --- packages/webapp/pages/feeds/[slugOrId]/edit.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/webapp/pages/feeds/[slugOrId]/edit.tsx b/packages/webapp/pages/feeds/[slugOrId]/edit.tsx index b661d1e3ad0..1555da0953b 100644 --- a/packages/webapp/pages/feeds/[slugOrId]/edit.tsx +++ b/packages/webapp/pages/feeds/[slugOrId]/edit.tsx @@ -44,9 +44,7 @@ const EditFeedPage = (): ReactElement | null => { // Pre-chips behavior: non-Plus cannot edit custom feeds. Once the chips // feature is on, free users own their custom feeds and the editor opens. const isFeedEditRestricted = - !isFeedTagChipsEnabled && - !isPlus && - feed?.node.type === FeedType.Custom; + !isFeedTagChipsEnabled && !isPlus && feed?.node.type === FeedType.Custom; useEffect(() => { document.body.classList.add('hidden-scrollbar'); From 8f8016e097d064da9801cbaecaf47c0ac047fd42 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 10:30:14 +0200 Subject: [PATCH 3/9] fix: tests --- packages/shared/src/hooks/feed/useFeeds.spec.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/shared/src/hooks/feed/useFeeds.spec.tsx b/packages/shared/src/hooks/feed/useFeeds.spec.tsx index 9750c91aa25..51d702a27c8 100644 --- a/packages/shared/src/hooks/feed/useFeeds.spec.tsx +++ b/packages/shared/src/hooks/feed/useFeeds.spec.tsx @@ -86,6 +86,9 @@ describe('useFeeds hook', () => { mockGraphQL({ request: { query: FEED_LIST_QUERY, + variables: { + includeTagChipFeeds: false, + }, }, result: () => { queryCalled = true; From dc565ef30c4c54fccbc4fa54d12956237e6d5e6a Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 11:29:10 +0200 Subject: [PATCH 4/9] fix: strict --- .../FeedSettings/FeedSettingsEditHeader.tsx | 4 +-- .../FeedSettings/useFeedSettingsEdit.tsx | 35 +++++++++++++------ .../sidebar/sections/CustomFeedSection.tsx | 8 ++--- .../shared/src/hooks/feed/useFeeds.spec.tsx | 8 ++--- packages/shared/src/hooks/feed/useFeeds.ts | 5 ++- 5 files changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx index 1f7ddd655a5..fc50351232e 100644 --- a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx @@ -88,7 +88,7 @@ const SaveButton = ({ activeView }: { activeView: string }): ReactElement => { ); }; -export const FeedSettingsEditHeader = (): ReactElement => { +export const FeedSettingsEditHeader = (): ReactElement | null => { const { onDiscard, onBackToFeed, feed, onSubmit } = useContext( FeedSettingsEditContext, ); @@ -136,7 +136,7 @@ export const FeedSettingsEditHeader = (): ReactElement => { } if (isMobile) { - setActiveView(undefined); + setActiveView?.(undefined); } else { onBackToFeed({ action: 'discard' }); } diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx index d5c368f1186..66ff96761cc 100644 --- a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx @@ -89,10 +89,10 @@ export const useFeedSettingsEdit = ({ const { user } = useAuthContext(); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); - const feed = useMemo(() => { + const feed = useMemo(() => { // calculate main feed from user object as fallback for now // in the future feedList can return main feed as well - if (feedSlugOrId === user.id) { + if (user && feedSlugOrId === user.id) { return { id: user.id, userId: user.id, @@ -101,8 +101,8 @@ export const useFeedSettingsEdit = ({ name: '', }, type: FeedType.Main, - createdAt: null, - }; + createdAt: new Date() + } } return feeds?.edges.find( @@ -129,7 +129,7 @@ export const useFeedSettingsEdit = ({ // remove new feed that was not modified by user on page unload useEventListener(globalThis?.window, 'beforeunload', () => { - if (isNewFeed && !isDirty) { + if (isNewFeed && !isDirty && feedId) { deleteFeed({ feedId }); } }); @@ -146,7 +146,9 @@ export const useFeedSettingsEdit = ({ } if (action === 'discard' && isNewFeed) { - await deleteFeed({ feedId }); + if (feedId) { + await deleteFeed({ feedId }); + } router.back(); @@ -183,8 +185,8 @@ export const useFeedSettingsEdit = ({ const feedData = useMemo(() => { return { - name: feed?.flags.name, - icon: feed?.flags.icon || '', + name: feed?.flags?.name ?? '', + icon: feed?.flags?.icon || '', ...feed?.flags, ...formState, }; @@ -192,6 +194,9 @@ export const useFeedSettingsEdit = ({ const { mutateAsync: onSubmit, isPending: isSubmitPending } = useMutation({ mutationFn: async () => { + if (!feedId) { + throw new Error('Cannot update feed without an id'); + } // Free users can only edit name + icon; advanced flags are Plus-only and // rejected server-side. Scope the payload so saving name/icon never trips // that guard on feeds that already carry advanced flags. @@ -276,6 +281,10 @@ export const useFeedSettingsEdit = ({ throw new Error('User cancelled deletion'); } + if (!feedId) { + throw new Error('Cannot delete feed without an id'); + } + const result = await deleteFeed({ feedId }); return result; @@ -306,7 +315,7 @@ export const useFeedSettingsEdit = ({ useEffect(() => { return () => { // cleanup on discard or navigation without save - cleanupRef.current(); + cleanupRef.current?.(); }; }, []); @@ -324,18 +333,22 @@ export const useFeedSettingsEdit = ({ onDelete, deleteStatus, onTagClick: useCallback(({ tag, action }) => { + if (!tag.name) { + return; + } setDirty(true); + const tagName = tag.name; if (action === 'follow') { setTagsToRemove((current) => { const newTags = { ...current }; - delete newTags[tag.name]; + delete newTags[tagName]; return newTags; }); } else { setTagsToRemove((current) => { - return { ...current, [tag.name]: true }; + return { ...current, [tagName]: true }; }); } }, []), diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx index 3cdaf193e1a..6e00d16463b 100644 --- a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx +++ b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx @@ -35,14 +35,14 @@ export const CustomFeedSection = ({ ].includes(defaultRenderSectionProps.activePage); return { - title: feed.node.flags.name || `Feed ${feed.node.id}`, + title: feed.node.flags?.name || `Feed ${feed.node.id}`, // on extension we don't use router so no need for a path // onNavTabClick takes care of the navigation path: isExtension ? undefined : '/', action: isExtension ? () => onNavTabClick?.('default') : undefined, - icon: feed.node.flags.icon || ( + icon: feed.node.flags?.icon || ( ), rightIcon: () => ( @@ -55,9 +55,9 @@ export const CustomFeedSection = ({ const feedPath = `${webappUrl}feeds/${feed.node.id}`; return { - title: feed.node.flags.name || `Feed ${feed.node.id}`, + title: feed.node.flags?.name || `Feed ${feed.node.id}`, path: feedPath, - icon: feed.node.flags.icon || ( + icon: feed.node.flags?.icon || ( diff --git a/packages/shared/src/hooks/feed/useFeeds.spec.tsx b/packages/shared/src/hooks/feed/useFeeds.spec.tsx index 51d702a27c8..940c2fba32e 100644 --- a/packages/shared/src/hooks/feed/useFeeds.spec.tsx +++ b/packages/shared/src/hooks/feed/useFeeds.spec.tsx @@ -187,7 +187,7 @@ describe('useFeeds hook', () => { await waitFor(() => expect(queryCalled).toBe(true)); expect(result.current.feeds).toBeTruthy(); - expect(result.current.feeds.edges).toMatchObject(feeds); + expect(result.current.feeds!.edges).toMatchObject(feeds); }); it('should create a feed', async () => { @@ -207,7 +207,7 @@ describe('useFeeds hook', () => { expect(feed).toBeTruthy(); expect(feed!.flags!.name).toBe('New feed'); expect( - result.current.feeds.edges.find((f) => f.node.id === feed!.id), + result.current.feeds!.edges.find((f) => f.node.id === feed!.id), ).toBeTruthy(); }); @@ -230,7 +230,7 @@ describe('useFeeds hook', () => { expect(feed).toBeTruthy(); expect(feed!.flags!.name).toBe('Updated feed'); expect( - result.current.feeds.edges.find((f) => f.node.id === feed!.id), + result.current.feeds!.edges.find((f) => f.node.id === feed!.id), ).toBeTruthy(); }); @@ -248,7 +248,7 @@ describe('useFeeds hook', () => { rerender(); expect( - result.current.feeds.edges.find((f) => f.node.id === 'cf1'), + result.current.feeds!.edges.find((f) => f.node.id === 'cf1'), ).toBeFalsy(); }); }); diff --git a/packages/shared/src/hooks/feed/useFeeds.ts b/packages/shared/src/hooks/feed/useFeeds.ts index 33270672f96..95001cb6461 100644 --- a/packages/shared/src/hooks/feed/useFeeds.ts +++ b/packages/shared/src/hooks/feed/useFeeds.ts @@ -25,7 +25,7 @@ export type UpdateFeedProps = { feedId: string } & CreateFeedProps; export type DeleteFeedProps = Pick; export type UseFeeds = { - feeds: FeedList['feedList']; + feeds: FeedList['feedList'] | undefined; createFeed: (props: CreateFeedProps) => Promise; updateFeed: (props: UpdateFeedProps) => Promise; deleteFeed: (props: DeleteFeedProps) => Promise>; @@ -82,6 +82,7 @@ export const useFeeds = (): UseFeeds => { onSuccess: (data) => { queryClient.setQueryData(queryKey, (current) => { return { + pageInfo: { hasNextPage: false }, ...current, edges: [ ...(current?.edges || []), @@ -111,6 +112,7 @@ export const useFeeds = (): UseFeeds => { onSuccess: (data) => { queryClient.setQueryData(queryKey, (current) => { return { + pageInfo: { hasNextPage: false }, ...current, edges: (current?.edges || []).map((edge) => { if (edge.node.id === data.id) { @@ -142,6 +144,7 @@ export const useFeeds = (): UseFeeds => { onSuccess: (data) => { queryClient.setQueryData(queryKey, (current) => { return { + pageInfo: { hasNextPage: false }, ...current, edges: (current?.edges || []).filter( (edge) => edge.node.id !== data.id, From 35f1cd3f8bbea9e98bc60cc1249dbcc0920ed494 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 11:53:24 +0200 Subject: [PATCH 5/9] fix: lint --- .../src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx index 66ff96761cc..763abc8fc66 100644 --- a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx @@ -101,8 +101,8 @@ export const useFeedSettingsEdit = ({ name: '', }, type: FeedType.Main, - createdAt: new Date() - } + createdAt: new Date(), + }; } return feeds?.edges.find( From 90bb0bd5c02460a9b8856688430bef8062d8d00c Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 13:13:46 +0200 Subject: [PATCH 6/9] feat: chips emoji --- packages/shared/src/components/feeds/NewStripCta.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/feeds/NewStripCta.tsx b/packages/shared/src/components/feeds/NewStripCta.tsx index 006b540cad6..dd37271501f 100644 --- a/packages/shared/src/components/feeds/NewStripCta.tsx +++ b/packages/shared/src/components/feeds/NewStripCta.tsx @@ -33,7 +33,7 @@ export const NewStripCta = ({ className, )} > - Want chips? + Want chips 🍟? ); From 62ce8c64858c641386c8be5110d1dc3ed24f3408 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 13:56:43 +0200 Subject: [PATCH 7/9] fix: feeds limit error --- .../feeds/FeedSettings/FeedSettingsCreate.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsCreate.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsCreate.tsx index 533686ff05d..56520ac56f4 100644 --- a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsCreate.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsCreate.tsx @@ -24,6 +24,7 @@ import { useContentPreference } from '../../../hooks/contentPreference/useConten import type { ContentPreferenceType } from '../../../graphql/contentPreference'; import { Loader } from '../../Loader'; import { FeedSettingsEdit } from './FeedSettingsEdit'; +import { webappUrl } from '../../../lib/constants'; export const FeedSettingsCreate = (): ReactElement => { const [newFeedId] = useState(() => Date.now().toString()); @@ -62,6 +63,7 @@ export const FeedSettingsCreate = (): ReactElement => { data: createdFeedData, mutate: onSubmit, isPending: isSubmitPending, + isError, } = useMutation({ mutationFn: createFeed, onSuccess: async (newFeed) => { @@ -96,6 +98,8 @@ export const FeedSettingsCreate = (): ReactElement => { ) { displayToast(labels.feed.error.feedLimit.client); + router.replace(webappUrl); + return; } @@ -122,7 +126,11 @@ export const FeedSettingsCreate = (): ReactElement => { }, [completeAction, user]); const shouldCreateNewFeed = - !!user && !isSubmitPending && !createdFeedData && !shouldUseQuickFeedCreate; + !!user && + !isSubmitPending && + !createdFeedData && + !shouldUseQuickFeedCreate && + !isError; useEffect(() => { if (!shouldCreateNewFeed) { From f4ce9468891e2497a744d5a58022a5380584b0c9 Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 16:19:00 +0200 Subject: [PATCH 8/9] feat: optimistic feed delete on new cancel --- .../feeds/FeedSettings/useFeedSettingsEdit.tsx | 4 +++- packages/shared/src/hooks/feed/useFeeds.ts | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx index 763abc8fc66..d9675d64970 100644 --- a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx @@ -147,7 +147,9 @@ export const useFeedSettingsEdit = ({ if (action === 'discard' && isNewFeed) { if (feedId) { - await deleteFeed({ feedId }); + // Fire-and-forget — onMutate updates the cache optimistically so + // navigation is instant; the server round-trip happens in the bg. + deleteFeed({ feedId }); } router.back(); diff --git a/packages/shared/src/hooks/feed/useFeeds.ts b/packages/shared/src/hooks/feed/useFeeds.ts index 95001cb6461..f244c709309 100644 --- a/packages/shared/src/hooks/feed/useFeeds.ts +++ b/packages/shared/src/hooks/feed/useFeeds.ts @@ -141,16 +141,27 @@ export const useFeeds = (): UseFeeds => { return { id: feedId }; }, - onSuccess: (data) => { + onMutate: async ({ feedId }) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); queryClient.setQueryData(queryKey, (current) => { return { pageInfo: { hasNextPage: false }, ...current, edges: (current?.edges || []).filter( - (edge) => edge.node.id !== data.id, + (edge) => edge.node.id !== feedId, ), }; }); + + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) { + queryClient.setQueryData(queryKey, ctx.previous); + } + + displayToast(labels.error.generic); }, }); From cf91cff9d9060bd46d3250081a6f0049d87a9f9d Mon Sep 17 00:00:00 2001 From: capJavert Date: Fri, 29 May 2026 22:20:02 +0200 Subject: [PATCH 9/9] feat: default feed handling --- .../shared/src/components/MainFeedLayout.tsx | 14 ++- .../src/components/feeds/ExploreChipsBar.tsx | 52 +++++----- .../components/feeds/UnifiedMobileFeedNav.tsx | 59 +++++------- .../src/components/feeds/exploreCategories.ts | 96 +++++++++++++++++-- 4 files changed, 148 insertions(+), 73 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 5deef72a584..b163bdcadad 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -325,6 +325,7 @@ export default function MainFeedLayout({ const isChipStripPage = router.pathname === '/' || + router.pathname === '/my-feed' || router.pathname === '/explore/[tag]' || router.pathname === '/feeds/[slugOrId]' || router.pathname === '/feeds/[slugOrId]/edit'; @@ -336,8 +337,12 @@ export default function MainFeedLayout({ !!user && isLaptop && isChipStripPage && isFeedTagChipsEnabled; const { feeds } = useFeeds(); const exploreCategories = useMemo( - () => buildPersonalizedCategories(feeds?.edges ?? []), - [feeds?.edges], + () => + buildPersonalizedCategories(feeds?.edges ?? [], { + defaultFeedId, + isCustomDefaultFeed, + }), + [feeds?.edges, defaultFeedId, isCustomDefaultFeed], ); const chipsNode = useMemo( () => @@ -533,7 +538,10 @@ export default function MainFeedLayout({ actionButtons: feedWithActions && ( ), diff --git a/packages/shared/src/components/feeds/ExploreChipsBar.tsx b/packages/shared/src/components/feeds/ExploreChipsBar.tsx index 73f328f00cd..516f2cb5e29 100644 --- a/packages/shared/src/components/feeds/ExploreChipsBar.tsx +++ b/packages/shared/src/components/feeds/ExploreChipsBar.tsx @@ -9,6 +9,7 @@ import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; import { ElementPlaceholder } from '../ElementPlaceholder'; import { useLogContext } from '../../contexts/LogContext'; import type { ExploreCategory } from './exploreCategories'; +import { findActiveChipId } from './exploreCategories'; import { LogEvent } from '../../lib/log'; import { NewStripCta } from './NewStripCta'; @@ -22,14 +23,6 @@ const PLACEHOLDER_WIDTHS = ['w-20', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']; const FOR_YOU_CATEGORY_ID = 'foryou'; -const normalizePath = (p: string): string => { - const noQuery = p.split('?')[0]; - if (!noQuery || noQuery === '/') { - return '/'; - } - return noQuery.replace(/\/$/, ''); -}; - export function ExploreChipsBar({ categories, isPending, @@ -39,24 +32,34 @@ export function ExploreChipsBar({ const { isCustomDefaultFeed } = useCustomDefaultFeed(); const { logEvent } = useLogContext(); - const forYouCategory: ExploreCategory = useMemo( - () => ({ + const forYouCategory: ExploreCategory = useMemo(() => { + const path = isCustomDefaultFeed ? `${webappUrl}my-feed` : webappUrl; + return { id: FOR_YOU_CATEGORY_ID, label: 'For you', - path: isCustomDefaultFeed ? `${webappUrl}my-feed` : webappUrl, - }), - [isCustomDefaultFeed], - ); - const activePath = useMemo( - () => normalizePath(router.asPath), - [router.asPath], - ); + path, + // When a custom feed is the default, `/` shows that feed (not "For you" + // content) — restrict matching to `/my-feed`. Without a custom default + // `/` is MyFeed, so include both. + matchPaths: isCustomDefaultFeed + ? [`${webappUrl}my-feed`] + : [path, webappUrl, `${webappUrl}my-feed`], + }; + }, [isCustomDefaultFeed]); const allCategories = useMemo( () => [forYouCategory, ...categories], [forYouCategory, categories], ); + const activeId = useMemo( + () => + findActiveChipId(allCategories, router.asPath, { + preferId: FOR_YOU_CATEGORY_ID, + }), + [allCategories, router.asPath], + ); + const scrollRef = useRef(null); useEffect(() => { const active = scrollRef.current?.querySelector( @@ -66,7 +69,7 @@ export function ExploreChipsBar({ return; } active.scrollIntoView({ block: 'nearest', inline: 'center' }); - }, [activePath, allCategories]); + }, [activeId, allCategories]); return (
@@ -76,16 +79,7 @@ export function ExploreChipsBar({ > {allCategories.map((category) => { - // For You owns the homepage. Match it against both `/` and `/my-feed` - // so the user's default custom feed (also at `/`) doesn't steal the - // active state. - const isForYou = category.id === FOR_YOU_CATEGORY_ID; - const candidates = isForYou - ? [category.path, webappUrl, `${webappUrl}my-feed`] - : [category.path]; - const isActive = candidates.some( - (candidate) => normalizePath(candidate) === activePath, - ); + const isActive = category.id === activeId; return ( { - const isEditingFeed = - router.query.slugOrId === feed.id && router.pathname.endsWith('/edit'); - let feedPath = `${webappUrl}feeds/${feed.id}`; - if (!isEditingFeed && isCustomDefaultFeed && feed.id === defaultFeedId) { - feedPath = `${webappUrl}`; - } - const href = `${feedPath}${isEditingFeed ? '/edit' : ''}`; + const isDefault = isCustomDefaultFeed && feed.id === defaultFeedId; + const slugPath = `${webappUrl}feeds/${feed.slug}`; + const idPath = `${webappUrl}feeds/${feed.id}`; + const matchPaths = [ + slugPath, + idPath, + `${slugPath}/edit`, + `${idPath}/edit`, + ...(isDefault ? [webappUrl] : []), + ]; list.push({ id: `feed-${feed.id}`, label: feed.flags?.name || `Feed ${feed.id}`, - href, + href: isDefault ? webappUrl : idPath, + matchPaths, group: 'categories', }); }); @@ -162,6 +170,8 @@ function UnifiedMobileFeedNav(): ReactElement { id: 'hottakes', label: 'Hot Takes', href: `${webappUrl}?openModal=hottakes`, + // Modal trigger, not a route — never mark this chip active by path. + matchPaths: [], group: 'rest', }, ); @@ -180,33 +190,14 @@ function UnifiedMobileFeedNav(): ReactElement { isLoggedIn, isCustomDefaultFeed, sortedFeeds, - router.query.slugOrId, - router.pathname, defaultFeedId, shouldHideGameCenter, ]); - const activeId = useMemo(() => { - const normalize = (p: string): string => { - const noQuery = p?.split('?')?.[0]; - if (!noQuery || noQuery === '/') { - return '/'; - } - return noQuery.replace(/\/$/, ''); - }; - const path = normalize(router.asPath); - const matches = items.filter((item) => { - const candidates = item.matchPaths ?? [item.href]; - return candidates.some((candidate) => normalize(candidate) === path); - }); - if (!matches.length) { - return null; - } - // Prefer the For You match when multiple chips share the same path - // (e.g. user's default custom feed also lives at `/`). - const forYouMatch = matches.find((item) => item.id === 'foryou'); - return forYouMatch ? forYouMatch.id : matches[matches.length - 1].id; - }, [items, router.asPath]); + const activeId = useMemo( + () => findActiveChipId(items, router.asPath, { preferId: 'foryou' }), + [items, router.asPath], + ); const scrollRef = useRef(null); useEffect(() => { diff --git a/packages/shared/src/components/feeds/exploreCategories.ts b/packages/shared/src/components/feeds/exploreCategories.ts index 2b654ffbd00..68866223f3c 100644 --- a/packages/shared/src/components/feeds/exploreCategories.ts +++ b/packages/shared/src/components/feeds/exploreCategories.ts @@ -7,15 +7,97 @@ export type ExploreCategory = { label: string; path: string; tag?: string; + matchPaths?: string[]; }; -// Feeds are rendered in the order the API returns them. +type BuildPersonalizedCategoriesOptions = { + defaultFeedId?: string; + isCustomDefaultFeed?: boolean; +}; + +// Strip the query string and (in the extension build, where `webappUrl` is +// absolute) the host so candidate URLs and `router.asPath` compare uniformly. +export const normalizePath = (p?: string): string => { + let path = p?.split('?')[0] || ''; + if (/^https?:\/\//.test(path)) { + try { + path = new URL(path).pathname; + } catch { + // fall through with the original string + } + } + if (!path || path === '/') { + return path; + } + return path.replace(/\/$/, ''); +}; + +type Matchable = { + id: string; + matchPaths?: string[]; + path?: string; + href?: string; +}; + +// Single source of truth for "which chip is active for the current URL" — used +// by both the desktop chip strip and the mobile feed nav. Returns null when no +// chip matches OR when multiple non-preferred chips match (don't guess). +export const findActiveChipId = ( + items: Matchable[], + asPath: string, + { preferId }: { preferId?: string } = {}, +): string | null => { + const normalized = normalizePath(asPath); + const matches = items.filter((item) => { + const candidates = item.matchPaths ?? [item.path ?? item.href ?? '']; + return candidates.some( + (candidate) => normalizePath(candidate) === normalized, + ); + }); + if (!matches.length) { + return null; + } + if (preferId) { + const preferred = matches.find((item) => item.id === preferId); + if (preferred) { + return preferred.id; + } + } + if (matches.length === 1) { + return matches[0].id; + } + return null; +}; + +// Feeds are rendered in the order the API returns them. When a custom feed is +// set as the user's default, route it through `/` (matches sidebar/mobile nav) +// and accept the canonical `/feeds/`, `/feeds/`, and their `/edit` +// variants as "active" so the chip lights up regardless of how the user got +// to that feed's URL. export const buildPersonalizedCategories = ( edges: Edge[], + { + defaultFeedId, + isCustomDefaultFeed, + }: BuildPersonalizedCategoriesOptions = {}, ): ExploreCategory[] => - edges.map(({ node: feed }) => ({ - id: feed.id, - label: feed.flags?.name || `Feed ${feed.id}`, - path: `${webappUrl}feeds/${feed.slug}`, - tag: feed.id, - })); + edges.map(({ node: feed }) => { + const isDefault = !!isCustomDefaultFeed && feed.id === defaultFeedId; + const slugPath = `${webappUrl}feeds/${feed.slug}`; + const idPath = `${webappUrl}feeds/${feed.id}`; + const matchPaths = [ + slugPath, + idPath, + `${slugPath}/edit`, + `${idPath}/edit`, + ...(isDefault ? [webappUrl] : []), + ]; + + return { + id: feed.id, + label: feed.flags?.name || `Feed ${feed.id}`, + path: isDefault ? webappUrl : slugPath, + tag: feed.id, + matchPaths, + }; + });