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 && ( -