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;
+};