diff --git a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx index 52b4e94ebf2..9563e88e133 100644 --- a/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx +++ b/packages/shared/src/components/feeds/UnifiedMobileFeedNav.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import Link from '../utilities/Link'; import { PlusIcon } from '../icons'; import { useAuthContext } from '../../contexts/AuthContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import { useFeeds } from '../../hooks'; import { useSortedFeeds } from '../../hooks/feed/useSortedFeeds'; import useCustomDefaultFeed from '../../hooks/feed/useCustomDefaultFeed'; @@ -38,6 +39,10 @@ const chipInactiveClass = function UnifiedMobileFeedNav(): ReactElement { const router = useRouter(); const { isLoggedIn } = useAuthContext(); + const { optOutAchievements, optOutLevelSystem, optOutQuestSystem } = + useSettingsContext(); + const shouldHideGameCenter = + optOutAchievements && optOutLevelSystem && optOutQuestSystem; const { feeds } = useFeeds(); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const sortedFeeds = useSortedFeeds({ edges: feeds?.edges }); @@ -173,13 +178,15 @@ function UnifiedMobileFeedNav(): ReactElement { href: `${webappUrl}?openModal=hottakes`, group: 'rest', }, - { + ); + if (!shouldHideGameCenter) { + list.push({ id: 'gamecenter', label: 'Game Center', href: `${webappUrl}game-center`, group: 'rest', - }, - ); + }); + } } return list; @@ -191,6 +198,7 @@ function UnifiedMobileFeedNav(): ReactElement { router.pathname, defaultFeedId, personalizedTags, + shouldHideGameCenter, ]); const activeId = useMemo(() => { diff --git a/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx b/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx index 3afa319a09c..6b316e0be91 100644 --- a/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx +++ b/packages/shared/src/components/filters/AchievementTrackerButton.spec.tsx @@ -5,6 +5,7 @@ import type { UserAchievement } from '../../graphql/user/achievements'; import { AchievementType } from '../../graphql/user/achievements'; const mockUseAuthContext = jest.fn(); +const mockUseSettingsContext = jest.fn(); const mockUseConditionalFeature = jest.fn(); const mockUseProfileAchievements = jest.fn(); const mockUseTrackedAchievement = jest.fn(); @@ -15,6 +16,10 @@ jest.mock('../../contexts/AuthContext', () => ({ useAuthContext: () => mockUseAuthContext(), })); +jest.mock('../../contexts/SettingsContext', () => ({ + useSettingsContext: () => mockUseSettingsContext(), +})); + jest.mock('../../hooks/useConditionalFeature', () => ({ useConditionalFeature: () => mockUseConditionalFeature(), })); @@ -122,6 +127,7 @@ const defaultProfileAchievementsHook = { beforeEach(() => { jest.clearAllMocks(); mockUseAuthContext.mockReturnValue({ user: { id: 'u1' } }); + mockUseSettingsContext.mockReturnValue({ optOutAchievements: false }); mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false }); mockUseProfileAchievements.mockReturnValue(defaultProfileAchievementsHook); mockUseTrackedAchievement.mockReturnValue(defaultTrackedAchievementHook); diff --git a/packages/shared/src/components/filters/AchievementTrackerButton.tsx b/packages/shared/src/components/filters/AchievementTrackerButton.tsx index c1cea894ac5..f2a0a5a624d 100644 --- a/packages/shared/src/components/filters/AchievementTrackerButton.tsx +++ b/packages/shared/src/components/filters/AchievementTrackerButton.tsx @@ -6,6 +6,7 @@ import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { MedalBadgeIcon } from '../icons'; import { AlertColor, AlertDot } from '../AlertDot'; import { useAuthContext } from '../../contexts/AuthContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { useProfileAchievements } from '../../hooks/profile/useProfileAchievements'; import { useTrackedAchievement } from '../../hooks/profile/useTrackedAchievement'; @@ -44,6 +45,7 @@ function AchievementIcon({ export function AchievementTrackerButton(): ReactElement | null { const { openModal, closeModal } = useLazyModal(); const { user } = useAuthContext(); + const { optOutAchievements } = useSettingsContext(); const isLaptop = useViewSize(ViewSize.Laptop); const { value: isAchievementTrackingWidgetEnabled, @@ -129,7 +131,7 @@ export function AchievementTrackerButton(): ReactElement | null { }); }; - if (!user || isAchievementTrackingWidgetLoading) { + if (!user || isAchievementTrackingWidgetLoading || optOutAchievements) { return null; } diff --git a/packages/shared/src/components/modals/BootPopups.tsx b/packages/shared/src/components/modals/BootPopups.tsx index 64bfe4390bb..0e350df2ae8 100644 --- a/packages/shared/src/components/modals/BootPopups.tsx +++ b/packages/shared/src/components/modals/BootPopups.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import React, { useContext, useEffect, useState } from 'react'; import { useLazyModal } from '../../hooks/useLazyModal'; import { useAuthContext } from '../../contexts/AuthContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import { useActions, useBoot } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import { LazyModal } from './common/types'; @@ -63,6 +64,7 @@ export const BootPopups = (): ReactElement => { const { checkHasCompleted, isActionsFetched } = useActions(); const { openModal } = useLazyModal(); const { user, isValidRegion } = useAuthContext(); + const { optOutAchievements } = useSettingsContext(); const { updateUserProfile } = useProfileForm(); const { alerts, @@ -304,7 +306,11 @@ export const BootPopups = (): ReactElement => { * Bypasses the one-per-day queue so users see their unlock right away. */ useEffect(() => { - if (!alerts?.showAchievementUnlock || !isActionsFetched) { + if ( + !alerts?.showAchievementUnlock || + !isActionsFetched || + optOutAchievements + ) { return; } @@ -329,6 +335,7 @@ export const BootPopups = (): ReactElement => { alerts?.showAchievementUnlock, checkHasCompleted, isActionsFetched, + optOutAchievements, updateAlerts, ]); diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index a1b2dd22d3b..e2bae5205de 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -53,6 +53,7 @@ import type { import { ProfileSection } from '../ProfileMenu/ProfileSection'; import { LogoutReason } from '../../lib/user'; import { logout, useAuthContext } from '../../contexts/AuthContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import type { WithClassNameProps } from '../utilities'; import { HorizontalSeparator } from '../utilities'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; @@ -84,6 +85,10 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); const { user } = useAuthContext(); + const { optOutAchievements, optOutLevelSystem, optOutQuestSystem } = + useSettingsContext(); + const shouldHideGameCenter = + optOutAchievements && optOutLevelSystem && optOutQuestSystem; const items = useMemo( () => @@ -207,12 +212,14 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { playground: { title: 'Gamification', items: { - gameCenter: { - title: 'Game Center', - icon: JoystickIcon, - href: `${webappUrl}game-center`, - external: true, - }, + ...(!shouldHideGameCenter && { + gameCenter: { + title: 'Game Center', + icon: JoystickIcon, + href: `${webappUrl}game-center`, + external: true, + }, + }), gamification: { title: 'Feature visibility', icon: EyeIcon, @@ -223,12 +230,14 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: HotIcon, href: `${settingsUrl}/customization/streaks`, }, - achievements: { - title: 'Achievements', - icon: MedalBadgeIcon, - href: `${webappUrl}${user?.username}/achievements`, - external: true, - }, + ...(!optOutAchievements && { + achievements: { + title: 'Achievements', + icon: MedalBadgeIcon, + href: `${webappUrl}${user?.username}/achievements`, + external: true, + }, + }), hotTakes: { title: 'Hot Takes', icon: HotIcon, @@ -337,7 +346,14 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { }, }, }), - [logEvent, onClose, openModal, user?.username], + [ + logEvent, + onClose, + openModal, + user?.username, + optOutAchievements, + shouldHideGameCenter, + ], ); return { items }; diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 1590cb9b697..d3084a3d90d 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -54,7 +54,10 @@ export interface SettingsContextData extends Omit { toggleOptOutReadingStreak: () => Promise; toggleOptOutLevelSystem: () => Promise; toggleOptOutQuestSystem: () => Promise; + toggleOptOutAchievements: () => Promise; toggleOptOutCompanion: () => Promise; + isGamificationEnabled: boolean; + toggleAllGamification: () => Promise; toggleAutoDismissNotifications: () => Promise; toggleShowFeedbackButton: () => Promise; loadedSettings: boolean; @@ -130,6 +133,7 @@ const defaultSettings: RemoteSettings = { optOutReadingStreak: false, optOutLevelSystem: false, optOutQuestSystem: false, + optOutAchievements: false, // Companion is opt-in: it injects a side panel into every article page, // which only some users want. Default to off so new users see a clean // feed and can flip the toggle in the customize sidebar's Widgets @@ -271,6 +275,35 @@ export const SettingsContextProvider = ({ ...settings, optOutQuestSystem: !settings.optOutQuestSystem, }), + toggleOptOutAchievements: () => + setSettings({ + ...settings, + optOutAchievements: !settings.optOutAchievements, + }), + isGamificationEnabled: + !settings.optOutReadingStreak || + !settings.optOutLevelSystem || + !settings.optOutQuestSystem || + !settings.optOutAchievements, + toggleAllGamification: () => { + const anyEnabled = + !settings.optOutReadingStreak || + !settings.optOutLevelSystem || + !settings.optOutQuestSystem || + !settings.optOutAchievements; + if (anyEnabled && !settings.optOutReadingStreak) { + unsubscribePersonalizedDigest({ + type: UserPersonalizedDigestType.StreakReminder, + }); + } + return setSettings({ + ...settings, + optOutReadingStreak: anyEnabled, + optOutLevelSystem: anyEnabled, + optOutQuestSystem: anyEnabled, + optOutAchievements: anyEnabled, + }); + }, toggleOptOutCompanion: () => setSettings({ ...settings, diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index 406548ec4b2..2db7735133e 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { startOfTomorrow, subDays, subMonths } from 'date-fns'; import dynamic from 'next/dynamic'; import { useAuthContext } from '../../../../contexts/AuthContext'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { ActiveOrRecomendedSquads } from './ActiveOrRecomendedSquads'; import type { ProfileReadingData, ProfileV2 } from '../../../../graphql/users'; import { USER_READING_HISTORY_QUERY } from '../../../../graphql/users'; @@ -54,6 +55,7 @@ export function ProfileWidgets({ className, }: ProfileWidgetsProps): ReactElement { const { user: loggedUser, tokenRefreshed } = useAuthContext(); + const { optOutAchievements } = useSettingsContext(); const { showIndicator: showProfileCompletion } = useProfileCompletionIndicator(); const { isPreviewMode, isOwner, togglePreview } = useProfilePreview(user); @@ -137,13 +139,19 @@ export function ProfileWidgets({ mostReadTags={readingHistory?.userMostReadTags} isLoading={isReadingHistoryLoading} /> - {shouldShowTrackingWidget && } + {shouldShowTrackingWidget && !optOutAchievements && ( + + )} {(isOwner || squads.length > 0) && ( )} - - + {!optOutAchievements && ( + <> + + + + )} ); } diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index 3c32e63e381..b2801030e6a 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -50,11 +50,11 @@ const readHistoryToTooltip = ( }; export interface ReadingOverviewProps { - readHistory: UserReadHistory[]; + readHistory?: UserReadHistory[]; before: Date; after: Date; - streak: UserStreak; - mostReadTags: MostReadTag[]; + streak?: UserStreak; + mostReadTags?: MostReadTag[]; isLoading?: boolean; } diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx index 2debb0ebc8e..60c425ef477 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import type { PublicProfile } from '../../../../lib/user'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; import { useShowcaseAchievements } from '../../../../hooks/profile/useShowcaseAchievements'; import { useProfilePreview } from '../../../../hooks/profile/useProfilePreview'; import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; @@ -35,10 +36,15 @@ export function ProfileAchievementShowcase({ user, }: ProfileAchievementShowcaseProps): ReactElement | null { const { isOwner } = useProfilePreview(user); + const { optOutAchievements } = useSettingsContext(); const { showcaseAchievements } = useShowcaseAchievements(user); const { achievements } = useProfileAchievements(user); const { openModal } = useLazyModal(); + if (optOutAchievements) { + return null; + } + const hasShowcase = showcaseAchievements.length > 0; const unlockedAchievements = diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index 40f66207557..8bcf9daee47 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -94,6 +94,7 @@ export type RemoteSettings = { optOutReadingStreak: boolean; optOutLevelSystem: boolean; optOutQuestSystem: boolean; + optOutAchievements: boolean; optOutCompanion: boolean; autoDismissNotifications: boolean; sortCommentsBy: SortCommentsBy; diff --git a/packages/webapp/pages/[userId]/achievements.tsx b/packages/webapp/pages/[userId]/achievements.tsx index 47d9190e8ea..658d9d0f817 100644 --- a/packages/webapp/pages/[userId]/achievements.tsx +++ b/packages/webapp/pages/[userId]/achievements.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; +import { useRouter } from 'next/router'; import { NextSeo } from 'next-seo'; import type { NextSeoProps } from 'next-seo/lib/types'; import GoBackHeaderMobile from '@dailydotdev/shared/src/components/post/GoBackHeaderMobile'; @@ -9,6 +10,7 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { ProfileAchievements } from '@dailydotdev/shared/src/features/profile/components/achievements/ProfileAchievements'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import type { ProfileLayoutProps } from '../../components/layouts/ProfileLayout'; import { getLayout as getProfileLayout, @@ -24,10 +26,22 @@ export const getStaticPaths = getProfileStaticPaths; const ProfileAchievementsPage = ({ user, noindex, -}: ProfileLayoutProps): ReactElement => { +}: ProfileLayoutProps): ReactElement | null => { const { user: loggedUser } = useContext(AuthContext); + const { optOutAchievements } = useSettingsContext(); + const router = useRouter(); const isSameUser = user && loggedUser?.id === user.id; + useEffect(() => { + if (optOutAchievements && user?.username) { + router.replace(`/${user.username}`); + } + }, [optOutAchievements, user?.username, router]); + + if (optOutAchievements || !user) { + return null; + } + const seo: NextSeoProps = { ...getProfileSeoDefaults( user, diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index cbe05f5c1e0..58e78106674 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -237,7 +237,20 @@ function GameCenterPage({ }: GameCenterPageProps): ReactElement { const router = useRouter(); const { user } = useAuthContext(); - const { optOutLevelSystem } = useSettingsContext(); + const { + optOutLevelSystem, + optOutQuestSystem, + optOutAchievements, + loadedSettings, + } = useSettingsContext(); + const isGameCenterEmpty = + optOutLevelSystem && optOutQuestSystem && optOutAchievements; + + useEffect(() => { + if (loadedSettings && isGameCenterEmpty) { + router.replace('/'); + } + }, [loadedSettings, isGameCenterEmpty, router]); const { value: isAchievementTrackingEnabled } = useConditionalFeature({ feature: achievementTrackingWidgetFeature, shouldEvaluate: !!user, @@ -274,6 +287,7 @@ function GameCenterPage({ ); const hasCoresAccess = useHasAccessToCores(); const showLevelSystem = !optOutLevelSystem; + const showAchievements = !optOutAchievements; const milestoneQuests = useMemo( () => questDashboard?.milestone ?? [], [questDashboard?.milestone], @@ -381,6 +395,14 @@ function GameCenterPage({ }; const handleMilestoneDestinationClick = useCallback( async (destination: QuestDestination) => { + if ('href' in destination) { + if (destination.openInNewTab) { + window.open(destination.href!, '_blank', 'noopener,noreferrer'); + return; + } + window.location.assign(destination.href!); + return; + } await router.push(destination.path); }, [router], @@ -813,79 +835,83 @@ function GameCenterPage({ -
-
- - Closest achievement - - {isFeaturedAchievementTrackable && ( - -
-
- {featuredAchievement && ( - - )} -
- - {featuredAchievement?.achievement.name ?? - 'No tracked achievement'} - + {showAchievements && ( +
+
- {featuredAchievement - ? `${featuredAchievement.progress}/${getTargetCount( - featuredAchievement.achievement, - )} progress` - : 'Once achievements load, your closest milestone shows here.'} + Closest achievement + {isFeaturedAchievementTrackable && ( + +
+
+ {featuredAchievement && ( + + )} +
+ + {featuredAchievement?.achievement.name ?? + 'No tracked achievement'} + + + {featuredAchievement + ? `${ + featuredAchievement.progress + }/${getTargetCount( + featuredAchievement.achievement, + )} progress` + : 'Once achievements load, your closest milestone shows here.'} + +
-
+ )}
@@ -934,24 +960,26 @@ function GameCenterPage({ ) : ( - <> - - Personal highlight - - - {achievementSummary.unlockedCount}/ - {achievementSummary.totalCount} - - - achievements unlocked so far - - + showAchievements && ( + <> + + Personal highlight + + + {achievementSummary.unlockedCount}/ + {achievementSummary.totalCount} + + + achievements unlocked so far + + + ) )} @@ -1096,26 +1124,30 @@ function GameCenterPage({ )} - + {showAchievements && ( + <> + -
- - - View all achievements - - - - ) : undefined - } - /> +
+ + + View all achievements + + + + ) : undefined + } + /> - {achievementShelfContent} -
+ {achievementShelfContent} +
+ + )} diff --git a/packages/webapp/pages/settings/customization/gamification.tsx b/packages/webapp/pages/settings/customization/gamification.tsx index 596de301e54..dba7e69a86f 100644 --- a/packages/webapp/pages/settings/customization/gamification.tsx +++ b/packages/webapp/pages/settings/customization/gamification.tsx @@ -14,15 +14,51 @@ import { SettingsSwitch } from '../../../components/layouts/SettingsLayout/commo const GamificationSettingsPage = (): ReactElement => { const { + optOutReadingStreak, optOutLevelSystem, optOutQuestSystem, + optOutAchievements, + isGamificationEnabled, + toggleOptOutReadingStreak, toggleOptOutLevelSystem, toggleOptOutQuestSystem, + toggleOptOutAchievements, + toggleAllGamification, } = useSettingsContext(); return (
+
+ + Show gamification features + + + + Master toggle for all gamification features. Turning this off hides + streaks, levels, quests, and achievements across daily.dev. + +
+ +
+ + Show reading streaks + + + + Toggle to display or hide your daily reading streaks. Turning + streaks off will not affect your activity or progress. + +
+
Show levels @@ -52,6 +88,22 @@ const GamificationSettingsPage = (): ReactElement => { Toggle to display or hide the quest system UI across the product.
+ +
+ + Show achievements + + + + Toggle to display or hide achievements, badges, and achievement + notifications. Turning achievements off will not affect your + progress or unlocks. + +
);