diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 20442a4e97..efdacb512a 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -28,6 +28,7 @@ import { } from '../contexts'; import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BootPopups } from './modals/BootPopups'; +import { StreakMilestonePopup } from './modals/streaks/StreakMilestonePopup'; import { useFeedName } from '../hooks/feed/useFeedName'; import { AuthTriggers } from '../lib/auth'; import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner'; @@ -180,6 +181,7 @@ function MainLayoutComponent({ + {plusEntryAnnouncementBar && ( { ActionType.DisableReadingStreakMilestone, ); - const shouldHideStreaksModal = [ - !isStreaksEnabled, - !isActionsFetched, - isNullOrUndefined(isDisabledMilestone), - isDisabledMilestone, - alerts?.showStreakMilestone !== true, - !streak?.current, - ].some(Boolean); const addBootPopup = (popup: BootPopupEntry) => { setBootPopups((prev) => new Map([...prev, [popup.type, popup]])); }; @@ -220,27 +212,6 @@ export const BootPopups = (): ReactElement => { }); }, [alerts?.showGenericReferral, updateLastBootPopup]); - /** - * Boot popup for streaks milestone - */ - useEffect(() => { - if (shouldHideStreaksModal) { - return; - } - - addBootPopup({ - type: LazyModal.NewStreak, - props: { - currentStreak: streak?.current, - maxStreak: streak?.max, - onAfterClose: () => { - updateLastBootPopup(); - updateAlerts({ showStreakMilestone: false }); - }, - }, - }); - }, [shouldHideStreaksModal, streak, updateAlerts, updateLastBootPopup]); - /** * Streak recovery modal */ @@ -271,7 +242,6 @@ export const BootPopups = (): ReactElement => { isActionsFetched, isDisabledMilestone, isStreaksEnabled, - shouldHideStreaksModal, streak, updateAlerts, updateLastBootPopup, diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx new file mode 100644 index 0000000000..3f71de4384 --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.spec.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import nock from 'nock'; +import { render, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Alerts } from '../../../graphql/alerts'; +import { StreakMilestonePopup } from './StreakMilestonePopup'; +import * as actionHook from '../../../hooks/useActions'; +import * as streakHook from '../../../hooks/streaks/useReadingStreak'; +import { ActionType } from '../../../graphql/actions'; +import { LazyModal } from '../common/types'; +import { MODAL_KEY } from '../../../hooks/useLazyModal'; +import { DayOfWeek } from '../../../lib/date'; + +const defaultAlerts: Alerts = { + filter: true, + rankLastSeen: null, + bootPopup: true, + showStreakMilestone: true, +}; + +const checkHasCompleted = jest.fn().mockReturnValue(false); +const updateAlerts = jest.fn(); + +type ReadingStreakReturn = ReturnType; + +const defaultStreak: ReadingStreakReturn = { + isLoading: false, + isStreaksEnabled: true, + isUpdatingConfig: false, + streak: { + current: 5, + max: 5, + total: 5, + weekStart: DayOfWeek.Monday, + }, + updateStreakConfig: jest.fn(), + checkReadingStreak: jest.fn(), +}; + +const renderComponent = ({ + alerts = defaultAlerts, + streak = defaultStreak, +}: { + alerts?: Alerts; + streak?: ReadingStreakReturn; +} = {}) => { + const queryClient = new QueryClient(); + + jest.spyOn(streakHook, 'useReadingStreak').mockReturnValue(streak); + + return { + queryClient, + ...render( + + + , + ), + }; +}; + +beforeEach(() => { + window.scrollTo = jest.fn(); + + jest.spyOn(actionHook, 'useActions').mockReturnValue({ + completeAction: jest.fn(), + checkHasCompleted, + isActionsFetched: true, + actions: [], + }); + + checkHasCompleted.mockReset().mockReturnValue(false); + updateAlerts.mockReset(); + nock.cleanAll(); + nock('http://localhost:3000') + .post('/graphql') + .optionally() + .times(10) + .reply(200, { data: {} }); +}); + +it('should open milestone modal when all conditions are met', async () => { + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ + type: LazyModal.NewStreak, + props: { currentStreak: 5, maxStreak: 5 }, + }); + }); +}); + +it('should not open when showStreakMilestone is false', async () => { + const { queryClient } = renderComponent({ + alerts: { ...defaultAlerts, showStreakMilestone: false }, + }); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toBeUndefined(); + }); +}); + +it('should not open when streak is 0', async () => { + const { queryClient } = renderComponent({ + streak: { + ...defaultStreak, + streak: { current: 0, max: 5, total: 5, weekStart: DayOfWeek.Monday }, + }, + }); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toBeUndefined(); + }); +}); + +it('should not open when user disabled milestone popup', async () => { + checkHasCompleted.mockImplementation( + (action: ActionType) => action === ActionType.DisableReadingStreakMilestone, + ); + + const { queryClient } = renderComponent(); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toBeUndefined(); + }); +}); + +it('should not open when streaks are disabled', async () => { + const { queryClient } = renderComponent({ + streak: { ...defaultStreak, isStreaksEnabled: false }, + }); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toBeUndefined(); + }); +}); + +it('should not open when another modal is already showing', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(MODAL_KEY, { + type: LazyModal.GenericReferral, + props: {}, + }); + + jest.spyOn(streakHook, 'useReadingStreak').mockReturnValue(defaultStreak); + + render( + + + , + ); + + await waitFor(() => { + const modal = queryClient.getQueryData(MODAL_KEY); + expect(modal).toMatchObject({ type: LazyModal.GenericReferral }); + }); +}); diff --git a/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx new file mode 100644 index 0000000000..e05d937ed6 --- /dev/null +++ b/packages/shared/src/components/modals/streaks/StreakMilestonePopup.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import { useContext, useEffect } from 'react'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { useActions } from '../../../hooks'; +import { ActionType } from '../../../graphql/actions'; +import { LazyModal } from '../common/types'; +import AlertContext from '../../../contexts/AlertContext'; +import { useReadingStreak } from '../../../hooks/streaks'; +import { isNullOrUndefined } from '../../../lib/func'; + +/** + * Standalone streak milestone modal trigger. + * + * Separated from BootPopups so it is NOT subject to the one-per-day + * boot popup queue. The modal opens as soon as all conditions are met + * (alerts loaded, streak data loaded, user eligible). + */ +export const StreakMilestonePopup = (): ReactElement => { + const { openModal, modal } = useLazyModal(); + const { checkHasCompleted, isActionsFetched } = useActions(); + const { alerts, loadedAlerts, updateAlerts } = useContext(AlertContext); + const { streak, isStreaksEnabled } = useReadingStreak(); + + const isDisabledMilestone = checkHasCompleted( + ActionType.DisableReadingStreakMilestone, + ); + + useEffect(() => { + const shouldHide = [ + !loadedAlerts, + !isStreaksEnabled, + !isActionsFetched, + isNullOrUndefined(isDisabledMilestone), + isDisabledMilestone, + alerts?.showStreakMilestone !== true, + !streak?.current, + !!modal, + ].some(Boolean); + + if (shouldHide) { + return; + } + + openModal({ + type: LazyModal.NewStreak, + props: { + currentStreak: streak?.current, + maxStreak: streak?.max, + onAfterClose: () => { + updateAlerts({ showStreakMilestone: false }); + }, + }, + }); + }, [ + alerts?.showStreakMilestone, + isActionsFetched, + isDisabledMilestone, + isStreaksEnabled, + loadedAlerts, + modal, + openModal, + streak, + updateAlerts, + ]); + + return null; +};