diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx
index d23a0a6d7de..87cf65c0906 100644
--- a/packages/shared/src/components/MainFeedLayout.tsx
+++ b/packages/shared/src/components/MainFeedLayout.tsx
@@ -71,6 +71,7 @@ import { isDevelopment, isProductionAPI, webappUrl } from '../lib/constants';
import { useReadingReminderHero } from '../hooks/notifications/useReadingReminderHero';
import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent';
import { useReadingReminderVariation } from '../hooks/notifications/useReadingReminderVariation';
+import { useNoAiFeed } from '../hooks/useNoAiFeed';
const FeedExploreHeader = dynamic(
() =>
@@ -220,6 +221,8 @@ export default function MainFeedLayout({
hasUser: !!user,
});
const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed();
+ const shouldEvaluateNoAi =
+ feedName === SharedFeedPage.MyFeed && !isCustomDefaultFeed;
const isLaptop = useViewSize(ViewSize.Laptop);
const feedVersion = useFeature(feature.feedVersion);
const { time, contentCurationFilter } = useSearchContextProvider();
@@ -289,6 +292,14 @@ export default function MainFeedLayout({
feature: customFeedVersion,
shouldEvaluate: feedName === SharedFeedPage.Custom,
});
+ const {
+ isNoAi,
+ isNoAiAvailable,
+ isLoaded: isNoAiLoaded,
+ toggleNoAi,
+ } = useNoAiFeed({
+ shouldEvaluate: shouldEvaluateNoAi,
+ });
const { isSearchPageLaptop } = useSearchResultsLayout();
@@ -352,6 +363,7 @@ export default function MainFeedLayout({
variables: {
...feedConfig.variables,
...dynamicFeedConfig?.variables,
+ ...(shouldEvaluateNoAi && isNoAi ? { noAi: true } : {}),
version:
isDevelopment && !isProductionAPI
? 1
@@ -372,6 +384,8 @@ export default function MainFeedLayout({
customFeedV,
tokenRefreshed,
feedVersion,
+ isNoAi,
+ shouldEvaluateNoAi,
]);
const [selectedAlgo, setSelectedAlgo, loadedAlgo] = usePersistentContext(
@@ -424,6 +438,10 @@ export default function MainFeedLayout({
return null;
}
+ if (shouldEvaluateNoAi && !isNoAiLoaded) {
+ return null;
+ }
+
if (feedNameProp === 'default' && isCustomDefaultFeed) {
if (!defaultFeedId) {
return null;
@@ -446,6 +464,15 @@ export default function MainFeedLayout({
),
};
@@ -525,6 +552,15 @@ export default function MainFeedLayout({
),
};
@@ -556,6 +592,11 @@ export default function MainFeedLayout({
isLaptop,
loadedAlgo,
tokenRefreshed,
+ shouldEvaluateNoAi,
+ isNoAiLoaded,
+ isNoAiAvailable,
+ isNoAi,
+ toggleNoAi,
]);
useEffect(() => {
diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection.tsx
index d17935739d0..3e4ff64c517 100644
--- a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection.tsx
+++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection.tsx
@@ -3,6 +3,9 @@ import React, { useContext, useMemo } from 'react';
import { FeedSettingsEditContext } from '../FeedSettingsEditContext';
import useFeedSettings from '../../../../hooks/useFeedSettings';
import { useAdvancedSettings } from '../../../../hooks/feed/useAdvancedSettings';
+import { useConditionalFeature, useToastNotification } from '../../../../hooks';
+import { useLogContext } from '../../../../contexts/LogContext';
+import { useSettingsContext } from '../../../../contexts/SettingsContext';
import {
getAdvancedContentTypes,
getContentCurationList,
@@ -14,7 +17,12 @@ import {
TypographyType,
} from '../../../typography/Typography';
import { FilterCheckbox } from '../../../fields/FilterCheckbox';
+import { Switch } from '../../../fields/Switch';
import { FeedType } from '../../../../graphql/feed';
+import { featureNoAiFeed } from '../../../../lib/featureManagement';
+import { SidebarSettingsFlags } from '../../../../graphql/settings';
+import { labels } from '../../../../lib/labels';
+import { LogEvent, Origin, TargetId } from '../../../../lib/log';
export const TOGGLEABLE_TYPES = ['Videos', 'Polls', 'Social'];
const CUSTOM_FEEDS_ONLY = ['Article'];
@@ -22,6 +30,9 @@ const ADVANCED_SETTINGS_KEY = 'advancedSettings';
export const FeedSettingsContentPreferencesSection = (): ReactElement => {
const { feed, editFeedSettings } = useContext(FeedSettingsEditContext);
+ const { flags, updateFlag } = useSettingsContext();
+ const { displayToast } = useToastNotification();
+ const { logEvent } = useLogContext();
const { advancedSettings } = useFeedSettings({ feedId: feed?.id });
const {
selectedSettings,
@@ -29,6 +40,10 @@ export const FeedSettingsContentPreferencesSection = (): ReactElement => {
checkSourceBlocked,
onToggleSource,
} = useAdvancedSettings({ feedId: feed?.id });
+ const { value: isNoAiFeatureEnabled } = useConditionalFeature({
+ feature: featureNoAiFeed,
+ shouldEvaluate: feed?.type === FeedType.Main,
+ });
const toggleableTypes = useMemo(
() =>
getAdvancedContentTypes(
@@ -63,7 +78,12 @@ export const FeedSettingsContentPreferencesSection = (): ReactElement => {
- {toggleableTypes.map(({ id, title, defaultEnabledState }) => {
+ {toggleableTypes.map((setting) => {
+ if (!setting) {
+ return null;
+ }
+
+ const { id, title, defaultEnabledState } = setting;
const isDisabled =
CUSTOM_FEEDS_ONLY.includes(title) &&
feed?.type !== FeedType.Custom;
@@ -93,6 +113,47 @@ export const FeedSettingsContentPreferencesSection = (): ReactElement => {
})}
+ {feed?.type === FeedType.Main && isNoAiFeatureEnabled && (
+
+
+
+ No AI mode
+
+
+ Keep AI topics filtered out across My Feed. You can hide the
+ homepage toggle once this is set.
+
+
+
{
+ const newState = !(flags?.noAiFeedEnabled ?? false);
+
+ editFeedSettings(() =>
+ updateFlag(SidebarSettingsFlags.NoAiFeedEnabled, newState),
+ );
+ displayToast(
+ newState ? labels.feed.noAi.hidden : labels.feed.noAi.visible,
+ );
+ logEvent({
+ event_name: LogEvent.ToggleNoAiFeed,
+ target_id: newState ? TargetId.On : TargetId.Off,
+ extra: JSON.stringify({
+ origin: Origin.Settings,
+ }),
+ });
+ }}
+ >
+ Keep AI topics filtered out
+
+
+ )}
@@ -105,20 +166,28 @@ export const FeedSettingsContentPreferencesSection = (): ReactElement => {
Pick the categories of content you'd like to see in your feed.
- {contentSourceList?.map(({ id, title, description, options }) => (
-
- editFeedSettings(() => onToggleSource(options.source))
- }
- descriptionClassName="text-text-tertiary"
- >
- {title}
-
- ))}
+ {contentSourceList?.map(({ id, title, description, options }) => {
+ if (!options?.source) {
+ return null;
+ }
+
+ const { source } = options;
+
+ return (
+
+ editFeedSettings(() => onToggleSource(source))
+ }
+ descriptionClassName="text-text-tertiary"
+ >
+ {title}
+
+ );
+ })}
{contentCurationList?.map(
({ id, title, description, defaultEnabledState }) => (
({
},
}));
+jest.mock('../tooltip/Tooltip', () => ({
+ Tooltip: function MockTooltip({
+ children,
+ }: {
+ children: React.ReactElement;
+ }) {
+ return children;
+ },
+}));
+
const mockUseAuthContext = useAuthContext as jest.Mock;
const mockUseLogContext = useLogContext as jest.Mock;
const mockUseActions = useActions as jest.Mock;
@@ -82,12 +94,47 @@ const mockUseQueryState = useQueryState as jest.Mock;
const mockCheckIsExtension = checkIsExtension as jest.Mock;
const mockGetCurrentBrowserName = getCurrentBrowserName as jest.Mock;
-const renderComponent = () =>
+const createActionsState = ({
+ dismissedInstallExtension = false,
+ dismissedNoAiToggle = false,
+ isActionsFetched = true,
+ completeAction = jest.fn(),
+}: {
+ dismissedInstallExtension?: boolean;
+ dismissedNoAiToggle?: boolean;
+ isActionsFetched?: boolean;
+ completeAction?: jest.Mock;
+} = {}) => ({
+ checkHasCompleted: jest.fn((type: ActionType) => {
+ if (type === ActionType.DismissInstallExtension) {
+ return dismissedInstallExtension;
+ }
+
+ if (type === ActionType.DismissNoAiFeedToggle) {
+ return dismissedNoAiToggle;
+ }
+
+ return false;
+ }),
+ completeAction,
+ isActionsFetched,
+});
+
+const renderComponent = ({
+ noAiState,
+}: {
+ noAiState?: {
+ isAvailable: boolean;
+ isEnabled: boolean;
+ onToggle: () => Promise;
+ };
+} = {}) =>
render(
,
);
@@ -116,11 +163,9 @@ describe('SearchControlHeader', () => {
});
it('does not render the install extension prompt before actions are fetched', () => {
- mockUseActions.mockReturnValue({
- checkHasCompleted: jest.fn().mockReturnValue(false),
- completeAction: jest.fn(),
- isActionsFetched: false,
- });
+ mockUseActions.mockReturnValue(
+ createActionsState({ isActionsFetched: false }),
+ );
renderComponent();
@@ -130,11 +175,9 @@ describe('SearchControlHeader', () => {
});
it('does not render the install extension prompt after dismissal', () => {
- mockUseActions.mockReturnValue({
- checkHasCompleted: jest.fn().mockReturnValue(true),
- completeAction: jest.fn(),
- isActionsFetched: true,
- });
+ mockUseActions.mockReturnValue(
+ createActionsState({ dismissedInstallExtension: true }),
+ );
renderComponent();
@@ -144,11 +187,7 @@ describe('SearchControlHeader', () => {
});
it('does not render the install extension prompt for extension users', () => {
- mockUseActions.mockReturnValue({
- checkHasCompleted: jest.fn().mockReturnValue(false),
- completeAction: jest.fn(),
- isActionsFetched: true,
- });
+ mockUseActions.mockReturnValue(createActionsState());
mockCheckIsExtension.mockReturnValue(true);
renderComponent();
@@ -159,11 +198,7 @@ describe('SearchControlHeader', () => {
});
it('does not render the install extension prompt after extension usage is recorded', () => {
- mockUseActions.mockReturnValue({
- checkHasCompleted: jest.fn().mockReturnValue(false),
- completeAction: jest.fn(),
- isActionsFetched: true,
- });
+ mockUseActions.mockReturnValue(createActionsState());
mockUseAuthContext.mockReturnValue({
user: { flags: { lastExtensionUse: '2025-01-01T00:00:00.000Z' } },
});
@@ -176,11 +211,7 @@ describe('SearchControlHeader', () => {
});
it('renders the install extension prompt when actions are fetched and not dismissed', () => {
- mockUseActions.mockReturnValue({
- checkHasCompleted: jest.fn().mockReturnValue(false),
- completeAction: jest.fn(),
- isActionsFetched: true,
- });
+ mockUseActions.mockReturnValue(createActionsState());
renderComponent();
@@ -188,4 +219,103 @@ describe('SearchControlHeader', () => {
screen.getByRole('link', { name: 'Get it for Chrome' }),
).toBeInTheDocument();
});
+
+ it('does not render the No AI switch when unavailable', () => {
+ mockUseActions.mockReturnValue(
+ createActionsState({ dismissedInstallExtension: true }),
+ );
+
+ renderComponent({
+ noAiState: {
+ isAvailable: false,
+ isEnabled: false,
+ onToggle: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ expect(screen.queryByText('No AI mode')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', { name: 'Toggle No AI mode' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders a No AI switch and logs when toggled', async () => {
+ const onToggle = jest.fn().mockResolvedValue(undefined);
+ const logEvent = jest.fn();
+ mockUseLogContext.mockReturnValue({ logEvent });
+ mockUseActions.mockReturnValue(
+ createActionsState({ dismissedInstallExtension: true }),
+ );
+
+ renderComponent({
+ noAiState: {
+ isAvailable: true,
+ isEnabled: false,
+ onToggle,
+ },
+ });
+
+ expect(screen.getByText('No AI mode')).toBeInTheDocument();
+ const switchInput = screen.getByRole('checkbox', {
+ name: 'Toggle No AI mode',
+ });
+ fireEvent.click(switchInput);
+
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(logEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.ToggleNoAiFeed,
+ target_id: TargetId.On,
+ extra: JSON.stringify({
+ origin: Origin.Feed,
+ }),
+ });
+ });
+ });
+
+ it('does not render the No AI switch after dismissal', () => {
+ mockUseActions.mockReturnValue(
+ createActionsState({
+ dismissedInstallExtension: true,
+ dismissedNoAiToggle: true,
+ }),
+ );
+
+ renderComponent({
+ noAiState: {
+ isAvailable: true,
+ isEnabled: false,
+ onToggle: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ expect(screen.queryByText('No AI mode')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', { name: 'Toggle No AI mode' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('dismisses the No AI header card', () => {
+ const completeAction = jest.fn();
+ mockUseActions.mockReturnValue(
+ createActionsState({
+ dismissedInstallExtension: true,
+ completeAction,
+ }),
+ );
+
+ renderComponent({
+ noAiState: {
+ isAvailable: true,
+ isEnabled: false,
+ onToggle: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss No AI mode' }));
+
+ expect(completeAction).toHaveBeenCalledWith(
+ ActionType.DismissNoAiFeedToggle,
+ );
+ });
});
diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx
index 6ab750346c2..f9a0b5816c5 100644
--- a/packages/shared/src/components/layout/common.tsx
+++ b/packages/shared/src/components/layout/common.tsx
@@ -31,9 +31,13 @@ import { useReadingStreak } from '../../hooks/streaks';
import type { AllFeedPages } from '../../lib/query';
import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState';
import type { AllowedTags, TypographyProps } from '../typography/Typography';
-import { Typography } from '../typography/Typography';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
import { ToggleClickbaitShield } from '../buttons/ToggleClickbaitShield';
-import { LogEvent, Origin } from '../../lib/log';
+import { LogEvent, Origin, TargetId } from '../../lib/log';
import { AchievementTrackerButton } from '../filters/AchievementTrackerButton';
import { ActionType } from '../../graphql/actions';
import {
@@ -43,14 +47,23 @@ import {
isNullOrUndefined,
} from '../../lib/func';
import { downloadBrowserExtension } from '../../lib/constants';
+import { cloudinaryNoAiFeedToggle } from '../../lib/image';
import { anchorDefaultRel } from '../../lib/strings';
import ConditionalWrapper from '../ConditionalWrapper';
+import { LazyImage } from '../LazyImage';
+import { Switch } from '../fields/Switch';
+import { Tooltip } from '../tooltip/Tooltip';
type State = [T, Dispatch>];
export interface SearchControlHeaderProps {
feedName: AllFeedPages;
algoState: State;
+ noAiState?: {
+ isAvailable: boolean;
+ isEnabled: boolean;
+ onToggle: () => void | Promise;
+ };
}
export const LayoutHeader = classed(
@@ -72,10 +85,18 @@ export const periodTexts = periods.map((period) => period.text);
export const DEFAULT_ALGORITHM_KEY = 'feed:algorithm';
export const DEFAULT_ALGORITHM_INDEX = 0;
+const noAiToggleTooltip = (
+ <>
+ For when you are tired of AI launches, hot takes,
+
+ and vibe-coding discourse taking over your feed.
+ >
+);
export const SearchControlHeader = ({
feedName,
algoState: [selectedAlgo, setSelectedAlgo],
+ noAiState,
}: SearchControlHeaderProps): ReactElement | null => {
const [selectedPeriod, setSelectedPeriod] = useQueryState({
key: [QueryStateKeys.FeedPeriod],
@@ -118,6 +139,9 @@ export const SearchControlHeader = ({
const hasDismissedInstallExtension = checkHasCompleted(
ActionType.DismissInstallExtension,
);
+ const hasDismissedNoAiFeedToggle = checkHasCompleted(
+ ActionType.DismissNoAiFeedToggle,
+ );
const canInstallExtension =
!checkIsExtension() && isNullOrUndefined(user?.flags?.lastExtensionUse);
const shouldShowInstallExtensionPrompt =
@@ -127,7 +151,6 @@ export const SearchControlHeader = ({
!hasDismissedInstallExtension;
const installExtensionButton = shouldShowInstallExtensionPrompt && (
-
);
- const actionButtons = [
+ const primaryActions = [
hasFeedActions && ,
isUpvoted ? (
),
hasFeedActions && ,
- isLaptop && installExtensionButton,
];
- const actions = actionButtons.filter((button) => !!button);
+ const shouldShowNoAiControl =
+ noAiState?.isAvailable && isActionsFetched && !hasDismissedNoAiFeedToggle;
+ const noAiControl = shouldShowNoAiControl
+ ? (() => {
+ const handleNoAiToggle = async () => {
+ const isEnabled = !noAiState.isEnabled;
+ await noAiState.onToggle();
+ logEvent({
+ event_name: LogEvent.ToggleNoAiFeed,
+ target_id: isEnabled ? TargetId.On : TargetId.Off,
+ extra: JSON.stringify({
+ origin: Origin.Feed,
+ }),
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ No AI mode
+
+
+
+
+
+ }
+ aria-label="Dismiss No AI mode"
+ onClick={() => completeAction(ActionType.DismissNoAiFeedToggle)}
+ />
+
+ );
+ })()
+ : null;
+ const secondaryActions = [noAiControl, isLaptop && installExtensionButton];
+ const actions = primaryActions.filter(Boolean);
+ const sideActions = secondaryActions.filter(Boolean);
return (
- {actions}
+
+
{actions}
+ {sideActions.length > 0 && (
+
{sideActions}
+ )}
+
);
};
diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts
index 61fab90d9f3..1b918eb10d9 100644
--- a/packages/shared/src/graphql/actions.ts
+++ b/packages/shared/src/graphql/actions.ts
@@ -59,6 +59,7 @@ export enum ActionType {
AchievementSyncPrompt = 'achievement_sync_prompt',
DisableAchievementCompletion = 'disable_achievement_completion',
DismissInstallExtension = 'dismiss_install_extension',
+ DismissNoAiFeedToggle = 'dismiss_no_ai_feed_toggle',
DismissBriefCard = 'dismiss_brief_card',
DigestUpsell = 'digest_upsell',
AskUpsellSearch = 'ask_upsell_search',
diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts
index 38ee910ad34..eb2a1c87279 100644
--- a/packages/shared/src/graphql/feed.ts
+++ b/packages/shared/src/graphql/feed.ts
@@ -125,6 +125,7 @@ export const FEED_QUERY = gql`
$after: String
$ranking: Ranking
$version: Int
+ $noAi: Boolean
${SUPPORTED_TYPES}
) {
page: feed(
@@ -132,6 +133,7 @@ export const FEED_QUERY = gql`
after: $after
ranking: $ranking
version: $version
+ noAi: $noAi
supportedTypes: $supportedTypes
) {
...FeedPostConnection
diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts
index e05943832af..5fead6dc4e2 100644
--- a/packages/shared/src/graphql/settings.ts
+++ b/packages/shared/src/graphql/settings.ts
@@ -17,6 +17,7 @@ export type SettingsFlags = {
sidebarResourcesExpanded: boolean;
sidebarBookmarksExpanded: boolean;
clickbaitShieldEnabled: boolean;
+ noAiFeedEnabled?: boolean;
timezoneMismatchIgnore?: string;
prompt?: Record;
lastPrompt?: string;
@@ -30,6 +31,7 @@ export enum SidebarSettingsFlags {
ResourcesExpanded = 'sidebarResourcesExpanded',
BookmarksExpanded = 'sidebarBookmarksExpanded',
ClickbaitShieldEnabled = 'clickbaitShieldEnabled',
+ NoAiFeedEnabled = 'noAiFeedEnabled',
}
export type RemoteSettings = {
diff --git a/packages/shared/src/hooks/useNoAiFeed.spec.ts b/packages/shared/src/hooks/useNoAiFeed.spec.ts
new file mode 100644
index 00000000000..9fede2cf3c7
--- /dev/null
+++ b/packages/shared/src/hooks/useNoAiFeed.spec.ts
@@ -0,0 +1,150 @@
+import { act, renderHook } from '@testing-library/react';
+import { useNoAiFeed } from './useNoAiFeed';
+import { useConditionalFeature } from './useConditionalFeature';
+import { useSettingsContext } from '../contexts/SettingsContext';
+import { ToastSubject, useToastNotification } from './useToastNotification';
+import { useLogContext } from '../contexts/LogContext';
+import { SidebarSettingsFlags } from '../graphql/settings';
+import { labels } from '../lib/labels';
+import { LogEvent, Origin, TargetId } from '../lib/log';
+import { useActions } from './useActions';
+import { ActionType } from '../graphql/actions';
+
+jest.mock('./useConditionalFeature', () => ({
+ useConditionalFeature: jest.fn(),
+}));
+
+jest.mock('../contexts/SettingsContext', () => ({
+ useSettingsContext: jest.fn(),
+}));
+
+jest.mock('./useToastNotification', () => ({
+ ...jest.requireActual('./useToastNotification'),
+ useToastNotification: jest.fn(),
+}));
+
+jest.mock('../contexts/LogContext', () => ({
+ useLogContext: jest.fn(),
+}));
+
+jest.mock('./useActions', () => ({
+ useActions: jest.fn(),
+}));
+
+const mockUseConditionalFeature = useConditionalFeature as jest.Mock;
+const mockUseSettingsContext = useSettingsContext as jest.Mock;
+const mockUseToastNotification = useToastNotification as jest.Mock;
+const mockUseLogContext = useLogContext as jest.Mock;
+const mockUseActions = useActions as jest.Mock;
+
+describe('useNoAiFeed', () => {
+ const updateFlag = jest.fn().mockResolvedValue(undefined);
+ const updatePromptFlag = jest.fn().mockResolvedValue(undefined);
+ const displayToast = jest.fn();
+ const logEvent = jest.fn();
+ const completeAction = jest.fn().mockResolvedValue(undefined);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseConditionalFeature.mockReturnValue({
+ value: true,
+ isLoading: false,
+ });
+ mockUseSettingsContext.mockReturnValue({
+ flags: {
+ noAiFeedEnabled: false,
+ prompt: {},
+ },
+ loadedSettings: true,
+ updateFlag,
+ updatePromptFlag,
+ });
+ mockUseToastNotification.mockReturnValue({ displayToast });
+ mockUseLogContext.mockReturnValue({ logEvent });
+ mockUseActions.mockReturnValue({ completeAction });
+ });
+
+ it('reads the saved no ai preference', () => {
+ mockUseSettingsContext.mockReturnValue({
+ flags: {
+ noAiFeedEnabled: true,
+ prompt: {},
+ },
+ loadedSettings: true,
+ updateFlag,
+ updatePromptFlag,
+ });
+
+ const { result } = renderHook(() => useNoAiFeed());
+
+ expect(result.current.isNoAi).toBe(true);
+ expect(result.current.isNoAiAvailable).toBe(true);
+ expect(result.current.isLoaded).toBe(true);
+ });
+
+ it('enables no ai mode locally and shows a save nudge toast', async () => {
+ const { result } = renderHook(() => useNoAiFeed());
+
+ await act(async () => {
+ await result.current.toggleNoAi();
+ });
+
+ expect(displayToast).toHaveBeenCalledWith(labels.feed.noAi.nudge.message, {
+ persistent: true,
+ subject: ToastSubject.Feed,
+ action: expect.objectContaining({
+ copy: labels.feed.noAi.nudge.action,
+ }),
+ });
+ expect(updatePromptFlag).toHaveBeenCalledWith(
+ 'no_ai_feed_preference_prompt',
+ true,
+ );
+
+ const toastAction = displayToast.mock.calls[0][1].action.onClick;
+
+ await act(async () => {
+ await toastAction();
+ });
+
+ expect(updateFlag).toHaveBeenCalledWith(
+ SidebarSettingsFlags.NoAiFeedEnabled,
+ true,
+ );
+ expect(completeAction).toHaveBeenCalledWith(
+ ActionType.DismissNoAiFeedToggle,
+ );
+ expect(logEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.SaveNoAiFeedPreference,
+ target_id: TargetId.On,
+ extra: JSON.stringify({
+ origin: Origin.Feed,
+ }),
+ });
+ });
+
+ it('disables the saved no ai preference from the header', async () => {
+ mockUseSettingsContext.mockReturnValue({
+ flags: {
+ noAiFeedEnabled: true,
+ prompt: {},
+ },
+ loadedSettings: true,
+ updateFlag,
+ updatePromptFlag,
+ });
+
+ const { result } = renderHook(() => useNoAiFeed());
+
+ await act(async () => {
+ await result.current.toggleNoAi();
+ });
+
+ expect(updateFlag).toHaveBeenCalledWith(
+ SidebarSettingsFlags.NoAiFeedEnabled,
+ false,
+ );
+ expect(displayToast).toHaveBeenCalledWith(labels.feed.noAi.visible);
+ });
+});
diff --git a/packages/shared/src/hooks/useNoAiFeed.ts b/packages/shared/src/hooks/useNoAiFeed.ts
new file mode 100644
index 00000000000..f27e9c3bfec
--- /dev/null
+++ b/packages/shared/src/hooks/useNoAiFeed.ts
@@ -0,0 +1,141 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { featureNoAiFeed } from '../lib/featureManagement';
+import { useSettingsContext } from '../contexts/SettingsContext';
+import { useConditionalFeature } from './useConditionalFeature';
+import { ToastSubject, useToastNotification } from './useToastNotification';
+import { labels } from '../lib/labels';
+import { SidebarSettingsFlags } from '../graphql/settings';
+import { useLogContext } from '../contexts/LogContext';
+import { LogEvent, Origin, TargetId } from '../lib/log';
+import { useActions } from './useActions';
+import { ActionType } from '../graphql/actions';
+
+type UseNoAiFeedProps = {
+ shouldEvaluate?: boolean;
+};
+
+type UseNoAiFeedReturn = {
+ isNoAi: boolean;
+ isNoAiAvailable: boolean;
+ isLoaded: boolean;
+ toggleNoAi: () => Promise;
+};
+
+const NO_AI_FEED_PROMPT_FLAG = 'no_ai_feed_preference_prompt';
+
+export const useNoAiFeed = ({
+ shouldEvaluate = true,
+}: UseNoAiFeedProps = {}): UseNoAiFeedReturn => {
+ const { value: featureEnabled, isLoading: isFeatureLoading } =
+ useConditionalFeature({
+ feature: featureNoAiFeed,
+ shouldEvaluate,
+ });
+ const { flags, loadedSettings, updateFlag, updatePromptFlag } =
+ useSettingsContext();
+ const { displayToast } = useToastNotification();
+ const { logEvent } = useLogContext();
+ const { completeAction } = useActions();
+ const [sessionNoAi, setSessionNoAi] = useState(false);
+
+ const savedNoAi = flags?.noAiFeedEnabled ?? false;
+ const hasSeenPrompt = !!flags?.prompt?.[NO_AI_FEED_PROMPT_FLAG];
+
+ useEffect(() => {
+ if (!savedNoAi || !sessionNoAi) {
+ return;
+ }
+
+ setSessionNoAi(false);
+ }, [savedNoAi, sessionNoAi]);
+
+ const persistNoAiPreference = useCallback(
+ async (value: boolean) => {
+ await updateFlag(SidebarSettingsFlags.NoAiFeedEnabled, value);
+ setSessionNoAi(false);
+ },
+ [updateFlag],
+ );
+
+ const maybeShowSavePreferenceNudge = useCallback(async () => {
+ if (savedNoAi || hasSeenPrompt) {
+ displayToast(labels.feed.noAi.hidden, {
+ subject: ToastSubject.Feed,
+ });
+
+ return;
+ }
+
+ displayToast(labels.feed.noAi.nudge.message, {
+ persistent: true,
+ subject: ToastSubject.Feed,
+ action: {
+ copy: labels.feed.noAi.nudge.action,
+ onClick: async () => {
+ await persistNoAiPreference(true);
+ await completeAction(ActionType.DismissNoAiFeedToggle);
+ logEvent({
+ event_name: LogEvent.SaveNoAiFeedPreference,
+ target_id: TargetId.On,
+ extra: JSON.stringify({
+ origin: Origin.Feed,
+ }),
+ });
+ },
+ },
+ });
+ await updatePromptFlag(NO_AI_FEED_PROMPT_FLAG, true);
+ }, [
+ completeAction,
+ displayToast,
+ hasSeenPrompt,
+ logEvent,
+ persistNoAiPreference,
+ savedNoAi,
+ updatePromptFlag,
+ ]);
+
+ const isNoAiAvailable = shouldEvaluate && featureEnabled;
+ const isNoAi = isNoAiAvailable && (savedNoAi || sessionNoAi);
+
+ const toggleNoAi = useCallback(async () => {
+ const nextIsNoAi = !isNoAi;
+
+ if (nextIsNoAi) {
+ setSessionNoAi(true);
+ await maybeShowSavePreferenceNudge();
+
+ return;
+ }
+
+ if (savedNoAi) {
+ await persistNoAiPreference(false);
+ displayToast(labels.feed.noAi.visible);
+
+ return;
+ }
+
+ setSessionNoAi(false);
+ displayToast(labels.feed.noAi.visible);
+ }, [
+ displayToast,
+ isNoAi,
+ maybeShowSavePreferenceNudge,
+ persistNoAiPreference,
+ savedNoAi,
+ ]);
+
+ const isLoaded =
+ !shouldEvaluate ||
+ (!isFeatureLoading && (!featureEnabled || loadedSettings));
+
+ return useMemo(
+ () => ({
+ isNoAi,
+ isNoAiAvailable,
+ isLoaded,
+ toggleNoAi,
+ }),
+ [isLoaded, isNoAi, isNoAiAvailable, toggleNoAi],
+ );
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 33aba4afeaf..2649b7a121f 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -32,6 +32,7 @@ export const upvotedFeedVersion = new Feature('upvoted_feed_version', 2);
export const discussedFeedVersion = new Feature('discussed_feed_version', 2);
export const latestFeedVersion = new Feature('latest_feed_version', 2);
export const customFeedVersion = new Feature('custom_feed_version', 2);
+export const featureNoAiFeed = new Feature('no_ai_feed', false);
// @ts-expect-error stale feature without default
export const plusTakeoverContent = new Feature<{
diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts
index 459ff1e1179..8f592da6505 100644
--- a/packages/shared/src/lib/image.ts
+++ b/packages/shared/src/lib/image.ts
@@ -207,6 +207,9 @@ export const cloudinaryAuthBannerBackground1920w =
export const cloudinaryAuthBannerBackground1440w =
'https://media.daily.dev/image/upload/s--lf8LUJjq--/c_auto,g_center,w_1440/f_auto//v1732012913/login-popover-dailydev_mxb7lw';
+export const cloudinaryNoAiFeedToggle =
+ 'https://media.daily.dev/image/upload/s--nndE59fs--/f_auto,q_auto/v1775238401/public/ChatGPT%20Image%20Mar%2031%2C%202026%2C%2011_00_27%20PM';
+
export const cloudinaryPWA =
'https://media.daily.dev/image/upload/s--_kFKAft3--/f_auto/v1735045791/web_-_safari_j52hcx';
diff --git a/packages/shared/src/lib/labels.ts b/packages/shared/src/lib/labels.ts
index b642a882b92..68cce216b6b 100644
--- a/packages/shared/src/lib/labels.ts
+++ b/packages/shared/src/lib/labels.ts
@@ -91,6 +91,15 @@ export const labels = {
smartPrompt: 'Smart Prompt setting has been applied for all feeds',
},
},
+ noAi: {
+ hidden: 'AI chatter hidden.',
+ visible: 'AI chatter is back.',
+ nudge: {
+ message:
+ 'AI chatter hidden. Want to hide this toggle too? You can change it later in Feed settings.',
+ action: 'Save & hide toggle',
+ },
+ },
},
integrations: {
prompt: {
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index cf25dbb2af2..fc0cef88308 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -278,6 +278,8 @@ export enum LogEvent {
ToggleClickbaitShield = 'toggle clickbait shield',
ClickbaitShieldTitle = 'clickbait shield title',
// End Clickbait Shield
+ ToggleNoAiFeed = 'toggle no ai feed',
+ SaveNoAiFeedPreference = 'save no ai feed preference',
InstallPWA = 'install pwa',
// Start Share
ShareProfile = 'share profile',