diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx new file mode 100644 index 0000000000..d7ffe74270 --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { HighlightCardOptions } from './HighlightCardOptions'; + +const mockSubscribe = jest.fn().mockResolvedValue(undefined); +const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); +const mockDisplayToast = jest.fn(); +const mockUseAuth = jest.fn(); +const mockUseConditionalFeature = jest.fn(); +const mockUseMajorHeadlinesSubscription = jest.fn(); +const mockRouterPush = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + +jest.mock('../../../contexts/AuthContext', () => ({ + useAuthContext: () => mockUseAuth(), +})); + +jest.mock('../../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: () => mockUseConditionalFeature(), +})); + +jest.mock('../../../hooks/notifications/useMajorHeadlinesSubscription', () => ({ + useMajorHeadlinesSubscription: () => mockUseMajorHeadlinesSubscription(), +})); + +jest.mock('../../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('../../tooltip/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const renderComponent = () => render(); + +describe('HighlightCardOptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ user: { id: '1' } }); + mockUseConditionalFeature.mockReturnValue({ value: true }); + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: false, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); + }); + + it('should render bell button when feature is on and user is logged in', () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: 'Get real-time alerts' }), + ).toBeInTheDocument(); + }); + + it('should not render for guests', () => { + mockUseAuth.mockReturnValue({ user: undefined }); + + renderComponent(); + + expect( + screen.queryByRole('button', { name: 'Get real-time alerts' }), + ).not.toBeInTheDocument(); + }); + + it('should not render when feature is off', () => { + mockUseConditionalFeature.mockReturnValue({ value: false }); + + renderComponent(); + + expect( + screen.queryByRole('button', { name: 'Get real-time alerts' }), + ).not.toBeInTheDocument(); + }); + + it('should subscribe and show toast with settings action when not subscribed', async () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Get real-time alerts' }), + ); + + await waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('feed_card'); + }); + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + "You'll be the first to know when news breaks.", + expect.objectContaining({ + action: expect.objectContaining({ copy: 'Settings' }), + }), + ); + }); + }); + + it('should navigate to notification settings when toast action is clicked', async () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Get real-time alerts' }), + ); + + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalled(); + }); + + const toastArgs = mockDisplayToast.mock.calls[0][1]; + toastArgs.action.onClick(); + + expect(mockRouterPush).toHaveBeenCalledWith('/settings/notifications'); + }); + + it('should unsubscribe when subscribed', async () => { + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: true, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); + + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Turn off real-time alerts' }), + ); + + await waitFor(() => { + expect(mockUnsubscribe).toHaveBeenCalledWith('feed_card'); + }); + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Real-time alerts turned off.', + ); + }); + }); +}); diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx new file mode 100644 index 0000000000..a0d9416689 --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -0,0 +1,93 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { BellAddIcon, BellSubscribedIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useMajorHeadlinesSubscription } from '../../../hooks/notifications/useMajorHeadlinesSubscription'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureMajorHeadlinesPush } from '../../../lib/featureManagement'; +import { useToastNotification } from '../../../hooks/useToastNotification'; + +const NOTIFICATION_SETTINGS_PATH = '/settings/notifications'; + +interface HighlightCardOptionsProps { + className?: string; +} + +const HighlightCardOptionsContent = ({ + className, +}: HighlightCardOptionsProps): ReactElement => { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const { displayToast } = useToastNotification(); + const { isSubscribed, isLoading, subscribe, unsubscribe } = + useMajorHeadlinesSubscription(); + + const handleToggle = async () => { + if (isPending || isLoading) { + return; + } + setIsPending(true); + try { + if (isSubscribed) { + await unsubscribe('feed_card'); + displayToast('Real-time alerts turned off.'); + return; + } + await subscribe('feed_card'); + displayToast("You'll be the first to know when news breaks.", { + action: { + copy: 'Settings', + onClick: () => router.push(NOTIFICATION_SETTINGS_PATH), + }, + }); + } finally { + setIsPending(false); + } + }; + + const label = isSubscribed + ? 'Turn off real-time alerts' + : 'Get real-time alerts'; + const Icon = isSubscribed ? BellSubscribedIcon : BellAddIcon; + + return ( + + {' '} + anytime. + + ) : ( + 'Be the first to know when something major happens in the developer world.' + )} +

+ + +
+ {!acceptedJustNow && ( + + )} +
+ + + ); +}; + +export default EnableHighlightsAlerts; diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 784d9d1cd6..f3616cfaa0 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -13,6 +13,7 @@ import { import { Tab, TabContainer } from '../tabs/TabContainer'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; +import { EnableHighlightsAlerts } from './EnableHighlightsAlerts'; const MAJOR_HEADLINES_LABEL = 'Headlines'; const SKELETON_COUNT = 5; @@ -148,6 +149,7 @@ export const HighlightsPage = (): ReactElement => { Happening Now + { const { logEvent } = useLogContext(); @@ -47,12 +50,17 @@ const InAppNotificationsTab = (): ReactElement => { const { isSubscribed, isInitialized, isPushSupported } = usePushNotificationContext(); const { openModal } = useLazyModal(); + const { user } = useAuthContext(); const { notificationSettings: ns, toggleSetting, toggleGroup, getGroupStatus, } = useNotificationSettings(); + const { value: isMajorHeadlinesEnabled } = useConditionalFeature({ + feature: featureMajorHeadlinesPush, + shouldEvaluate: !!user, + }); const onTogglePush = async () => { logEvent({ @@ -117,6 +125,30 @@ const InAppNotificationsTab = (): ReactElement => { } /> + {isMajorHeadlinesEnabled && ( + <> + + + Happening Now + + + + toggleSetting(NotificationType.MajorHeadlineAdded, 'inApp') + } + /> + + + + + )} Activity diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index cfc0bc6f08..13e1fb1bdf 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -94,6 +94,7 @@ export enum NotificationType { NewOpportunityMatch = 'new_opportunity_match', WarmIntro = 'warm_intro', ExperienceCompanyEnriched = 'experience_company_enriched', + MajorHeadlineAdded = 'major_headline_added', } export enum NotificationIconType { @@ -206,6 +207,7 @@ export const notificationTypeTheme: Partial> = [NotificationType.BriefingReady]: 'text-brand-default', [NotificationType.DigestReady]: 'text-brand-default', [NotificationType.UserFollow]: 'text-brand-default', + [NotificationType.MajorHeadlineAdded]: 'text-brand-default', }; export const notificationTypeNotClickable: Partial< diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 2cddf5b0a5..86d600cf86 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -63,6 +63,7 @@ export enum ActionType { DismissBriefCard = 'dismiss_brief_card', DigestUpsell = 'digest_upsell', AskUpsellSearch = 'ask_upsell_search', + DismissedMajorHeadlinesAlertsBanner = 'dismissed_major_headlines_alerts_banner', } export const cvActions = [ diff --git a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts new file mode 100644 index 0000000000..611a0d2034 --- /dev/null +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -0,0 +1,100 @@ +import { useCallback } from 'react'; +import { NotificationType } from '../../components/notifications/utils'; +import { NotificationPreferenceStatus } from '../../graphql/notifications'; +import useNotificationSettings from './useNotificationSettings'; +import { usePushNotificationMutation } from './usePushNotificationMutation'; +import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, NotificationPromptSource } from '../../lib/log'; + +type MajorHeadlinesOrigin = 'settings' | 'highlights_page' | 'feed_card'; + +type UseMajorHeadlinesSubscriptionResult = { + isSubscribed: boolean; + isPushEnabled: boolean; + isLoading: boolean; + subscribe: (origin: MajorHeadlinesOrigin) => Promise; + unsubscribe: (origin: MajorHeadlinesOrigin) => Promise; +}; + +const ORIGIN_TO_PROMPT_SOURCE: Record< + MajorHeadlinesOrigin, + NotificationPromptSource +> = { + settings: NotificationPromptSource.MajorHeadlinesSettings, + highlights_page: NotificationPromptSource.MajorHeadlinesPage, + feed_card: NotificationPromptSource.MajorHeadlinesCard, +}; + +export const useMajorHeadlinesSubscription = + (): UseMajorHeadlinesSubscriptionResult => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { isSubscribed: isPushEnabled } = usePushNotificationContext(); + const { onEnablePush } = usePushNotificationMutation(); + const { + notificationSettings, + isLoadingPreferences, + setNotificationStatusBulk, + } = useNotificationSettings(); + + const settings = + notificationSettings?.[NotificationType.MajorHeadlineAdded]; + const isSubscribed = + settings?.inApp === NotificationPreferenceStatus.Subscribed; + + const subscribe = useCallback( + async (origin: MajorHeadlinesOrigin) => { + if (!user) { + return; + } + + await onEnablePush(ORIGIN_TO_PROMPT_SOURCE[origin]); + + setNotificationStatusBulk([ + { + type: NotificationType.MajorHeadlineAdded, + channel: 'inApp', + status: NotificationPreferenceStatus.Subscribed, + }, + ]); + + logEvent({ + event_name: LogEvent.EnableMajorHeadlinesAlerts, + extra: JSON.stringify({ origin }), + }); + }, + [user, onEnablePush, setNotificationStatusBulk, logEvent], + ); + + const unsubscribe = useCallback( + async (origin: MajorHeadlinesOrigin) => { + if (!user) { + return; + } + + setNotificationStatusBulk([ + { + type: NotificationType.MajorHeadlineAdded, + channel: 'inApp', + status: NotificationPreferenceStatus.Muted, + }, + ]); + + logEvent({ + event_name: LogEvent.DisableMajorHeadlinesAlerts, + extra: JSON.stringify({ origin }), + }); + }, + [user, setNotificationStatusBulk, logEvent], + ); + + return { + isSubscribed, + isPushEnabled, + isLoading: isLoadingPreferences, + subscribe, + unsubscribe, + }; + }; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 5ef53dfa2a..c5d8199a1e 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -33,6 +33,10 @@ export const discussedFeedVersion = new Feature('discussed_feed_version', 2); export const latestFeedVersion = new Feature('latest_feed_version', 2); export const customFeedVersion = new Feature('custom_feed_version', 2); export const featureFeedV2Highlights = new Feature('feed_v2_highlights', false); +export const featureMajorHeadlinesPush = new Feature( + 'major_headlines_push', + false, +); export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index f313fc4e87..2a392afef6 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -128,6 +128,10 @@ export enum LogEvent { EnableNotification = 'enable notification', DisableNotification = 'disable notification', ScheduleDigest = 'schedule digest', + EnableMajorHeadlinesAlerts = 'enable major headlines alerts', + DisableMajorHeadlinesAlerts = 'disable major headlines alerts', + ImpressionMajorHeadlinesAlertsBanner = 'impression major headlines alerts banner', + DismissMajorHeadlinesAlertsBanner = 'dismiss major headlines alerts banner', // notifications - end // squads - start ViewSquadInvitation = 'view squad invitation', @@ -624,6 +628,9 @@ export enum NotificationPromptSource { SquadChecklist = 'squad checklist', SourceSubscribe = 'source subscribe', ReadingReminder = 'reading reminder', + MajorHeadlinesSettings = 'major headlines settings', + MajorHeadlinesPage = 'major headlines page', + MajorHeadlinesCard = 'major headlines card', } export enum ShortcutsSourceType {