Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,6 +181,7 @@ function MainLayoutComponent({
<PromptElement />
<Toast autoDismissNotifications={autoDismissNotifications} />
<BootPopups />
<StreakMilestonePopup />
{plusEntryAnnouncementBar && (
<PlusMobileEntryBanner
className="relative"
Expand Down
30 changes: 0 additions & 30 deletions packages/shared/src/components/modals/BootPopups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,6 @@ export const BootPopups = (): ReactElement => {
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]]));
};
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -271,7 +242,6 @@ export const BootPopups = (): ReactElement => {
isActionsFetched,
isDisabledMilestone,
isStreaksEnabled,
shouldHideStreaksModal,
streak,
updateAlerts,
updateLastBootPopup,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof streakHook.useReadingStreak>;

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(
<TestBootProvider
client={queryClient}
alerts={{ alerts, updateAlerts }}
settings={{ loadedSettings: true, optOutReadingStreak: false }}
>
<StreakMilestonePopup />
</TestBootProvider>,
),
};
};

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(
<TestBootProvider
client={queryClient}
alerts={{ alerts: defaultAlerts, updateAlerts }}
settings={{ loadedSettings: true, optOutReadingStreak: false }}
>
<StreakMilestonePopup />
</TestBootProvider>,
);

await waitFor(() => {
const modal = queryClient.getQueryData(MODAL_KEY);
expect(modal).toMatchObject({ type: LazyModal.GenericReferral });
});
});
Original file line number Diff line number Diff line change
@@ -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;
};