@@ -72,17 +77,9 @@ export function ExploreChipsBar({
ref={scrollRef}
className="no-scrollbar flex items-center gap-2 overflow-x-auto pr-12"
>
+
{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 [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) {
diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEdit.tsx
index 77267cfdffd..d7e0cf82bbe 100644
--- a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEdit.tsx
+++ b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEdit.tsx
@@ -20,6 +20,7 @@ import { FeedSettingsEditContext } from './FeedSettingsEditContext';
import { FeedSettingsEditHeader } from './FeedSettingsEditHeader';
import { FeedSettingsEditBody } from './FeedSettingsEditBody';
import { FeedSettingsTitle } from './FeedSettingsTitle';
+import { FeedSettingsPlusGate } from './FeedSettingsPlusGate';
import { FeedType } from '../../../graphql/feed';
import { SuspenseLoader } from './components/SuspenseLoader';
@@ -195,19 +196,27 @@ export const FeedSettingsEdit = (
-
+
+
+
-
+
+
+
-
+
+
+
-
+
+
+
diff --git a/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx b/packages/shared/src/components/feeds/FeedSettings/FeedSettingsEditHeader.tsx
index 187a8a29ccf..fc50351232e 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,
@@ -85,20 +88,30 @@ const SaveButton = ({ activeView }: { activeView: string }): ReactElement => {
);
};
-export const FeedSettingsEditHeader = (): ReactElement => {
+export const FeedSettingsEditHeader = (): ReactElement | null => {
const { onDiscard, onBackToFeed, feed, onSubmit } = useContext(
FeedSettingsEditContext,
);
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;
}
@@ -123,7 +136,7 @@ export const FeedSettingsEditHeader = (): ReactElement => {
}
if (isMobile) {
- setActiveView(undefined);
+ setActiveView?.(undefined);
} else {
onBackToFeed({ action: 'discard' });
}
@@ -131,7 +144,7 @@ export const FeedSettingsEditHeader = (): ReactElement => {
>
Cancel
- {!isPlus && feed?.type === FeedType.Custom ? (
+ {showPlusCta ? (
{
+ const { isPlus } = usePlusSubscription();
+
+ if (isPlus) {
+ return <>{children}>;
+ }
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ );
+};
diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx
index 5808d682005..4b65506a342 100644
--- a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx
+++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx
@@ -117,74 +117,76 @@ export const FeedSettingsGeneralSection = (): ReactElement => {
label="Choose an icon"
/>
)}
-
-
-
- Set as your default feed
-
-
- Make this feed the first one you see every time you open daily.dev.
-
+ {(isPlus || isMainFeed) && (
+
+
+
+ Set as your default feed
+
+
+ Make this feed the first one you see every time you open
+ daily.dev.
+
+
+ {isCustomFeed && (
+
:
}
+ onClick={async () =>
+ editFeedSettings(() =>
+ updateUserProfile({
+ defaultFeedId: isDefaultFeed ? null : feed.id,
+ }),
+ )
+ }
+ >
+ {isDefaultFeed ? 'Default feed set' : 'Make default'}
+
+ )}
+ {isMainFeed && (
+
+
+ : }
+ disabled={user.defaultFeedId === null}
+ onClick={async () => {
+ editFeedSettings(() =>
+ updateUserProfile({
+ defaultFeedId: null,
+ }),
+ );
+ }}
+ >
+ {isDefaultFeed ? 'Default feed set' : 'Make default'}
+
+
+
+ )}
- {isCustomFeed && (
-
:
}
- onClick={async () =>
- editFeedSettings(() =>
- updateUserProfile({
- defaultFeedId: isDefaultFeed ? null : feed.id,
- }),
- )
- }
- >
- {isDefaultFeed ? 'Default feed set' : 'Make default'}
-
- )}
- {isMainFeed && (
-
-
- : }
- disabled={user.defaultFeedId === null}
- onClick={async () => {
- editFeedSettings(() =>
- updateUserProfile({
- defaultFeedId: null,
- }),
- );
- }}
- >
- {isDefaultFeed ? 'Default feed set' : 'Make default'}
-
-
-
- )}
-
+ )}
diff --git a/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx b/packages/shared/src/components/feeds/FeedSettings/useFeedSettingsEdit.tsx
index 0adeddee495..d9675d64970 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 = {
@@ -83,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,
@@ -95,7 +101,7 @@ export const useFeedSettingsEdit = ({
name: '',
},
type: FeedType.Main,
- createdAt: null,
+ createdAt: new Date(),
};
}
@@ -123,24 +129,28 @@ 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 });
}
});
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;
}
if (action === 'discard' && isNewFeed) {
- await deleteFeed({ feedId });
+ if (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();
@@ -171,13 +181,14 @@ export const useFeedSettingsEdit = ({
deleteFeed,
feedId,
isPlus,
+ isFeedTagChipsEnabled,
],
);
const feedData = useMemo(() => {
return {
- name: feed?.flags.name,
- icon: feed?.flags.icon || '',
+ name: feed?.flags?.name ?? '',
+ icon: feed?.flags?.icon || '',
...feed?.flags,
...formState,
};
@@ -185,7 +196,16 @@ export const useFeedSettingsEdit = ({
const { mutateAsync: onSubmit, isPending: isSubmitPending } = useMutation({
mutationFn: async () => {
- const result = await updateFeed({ ...feedData, feedId });
+ 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.
+ 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,
@@ -263,6 +283,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;
@@ -293,7 +317,7 @@ export const useFeedSettingsEdit = ({
useEffect(() => {
return () => {
// cleanup on discard or navigation without save
- cleanupRef.current();
+ cleanupRef.current?.();
};
}, []);
@@ -311,18 +335,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/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..dd37271501f
--- /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 9563e88e133..9d4cac756e7 100644
--- a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx
+++ b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx
@@ -9,11 +9,11 @@ import { useSettingsContext } from '../../contexts/SettingsContext';
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';
+import { findActiveChipId } from './exploreCategories';
type ChipGroup = 'forYou' | 'categories' | 'rest';
@@ -46,7 +46,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(() => {
@@ -57,37 +56,32 @@ function UnifiedMobileFeedNav(): ReactElement {
id: 'foryou',
label: isLoggedIn ? 'For you' : 'Home',
href: myFeedHref,
- // Always match the homepage so "For you" stays highlighted even when the
- // default custom feed (also at `/`) would otherwise win.
- matchPaths: [myFeedHref, webappUrl, `${webappUrl}my-feed`],
+ // When a custom feed is the default, `/` shows that feed (not "For you"
+ // content) — so restrict matching to `/my-feed`. Without a custom default
+ // `/` is MyFeed, so include both.
+ matchPaths: isCustomDefaultFeed
+ ? [`${webappUrl}my-feed`]
+ : [myFeedHref, webappUrl, `${webappUrl}my-feed`],
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');
- 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,
- group: 'rest',
+ href: isDefault ? webappUrl : idPath,
+ matchPaths,
+ group: 'categories',
});
});
if (isLoggedIn) {
@@ -99,7 +93,7 @@ function UnifiedMobileFeedNav(): ReactElement {
),
href: `${webappUrl}feeds/new`,
- group: 'rest',
+ group: 'categories',
isIconOnly: true,
});
}
@@ -176,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',
},
);
@@ -194,34 +190,14 @@ function UnifiedMobileFeedNav(): ReactElement {
isLoggedIn,
isCustomDefaultFeed,
sortedFeeds,
- router.query.slugOrId,
- router.pathname,
defaultFeedId,
- personalizedTags,
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(() => {
@@ -239,6 +215,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..68866223f3c 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 = {
@@ -6,14 +7,97 @@ export type ExploreCategory = {
label: string;
path: string;
tag?: string;
+ matchPaths?: string[];
};
+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 = (
- tags: FeedTagsListItem[],
+ edges: Edge[],
+ {
+ defaultFeedId,
+ isCustomDefaultFeed,
+ }: BuildPersonalizedCategoriesOptions = {},
): ExploreCategory[] =>
- tags.map(({ value, label }) => ({
- id: value,
- label,
- path: `${webappUrl}explore/${value}`,
- tag: value,
- }));
+ 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,
+ };
+ });
diff --git a/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx b/packages/shared/src/components/sidebar/sections/CustomFeedSection.tsx
index 1cb8fb5c833..6e00d16463b 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,
- icon: feed.node.flags.icon || (
-
- ),
- rightIcon: () => (
-
+ title: feed.node.flags?.name || `Feed ${feed.node.id}`,
+ path: feedPath,
+ icon: feed.node.flags?.icon || (
+
),
- 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}
>
{
mockGraphQL({
request: {
query: FEED_LIST_QUERY,
+ variables: {
+ includeTagChipFeeds: false,
+ },
},
result: () => {
queryCalled = true;
@@ -184,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 () => {
@@ -204,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();
});
@@ -227,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();
});
@@ -245,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 020e449d583..f244c709309 100644
--- a/packages/shared/src/hooks/feed/useFeeds.ts
+++ b/packages/shared/src/hooks/feed/useFeeds.ts
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo } from 'react';
import type { FeedList, Feed } from '../../graphql/feed';
import {
FEED_LIST_QUERY,
@@ -11,6 +12,8 @@ import { useAuthContext } from '../../contexts/AuthContext';
import { labels } from '../../lib';
import { useToastNotification } from '../useToastNotification';
import { gqlClient } from '../../graphql/common';
+import { useConditionalFeature } from '../useConditionalFeature';
+import { featureFeedTagChips } from '../../lib/featureManagement';
export type CreateFeedProps = {
name: string;
@@ -22,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>;
@@ -31,18 +34,38 @@ export type UseFeeds = {
export const useFeeds = (): UseFeeds => {
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,
});
@@ -59,6 +82,7 @@ export const useFeeds = (): UseFeeds => {
onSuccess: (data) => {
queryClient.setQueryData(queryKey, (current) => {
return {
+ pageInfo: { hasNextPage: false },
...current,
edges: [
...(current?.edges || []),
@@ -88,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) {
@@ -116,15 +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);
},
});
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..1555da0953b 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,19 @@ 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(() => {