From ec12ebd33c0ac788e09994da8ab5a4bd7f344af3 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:06:20 +0300 Subject: [PATCH 01/12] feat: major headlines push notifications Add push + in-app notifications for Breaking/Major significance highlights with three opt-in surfaces (settings, /highlights banner, in-feed card menu) gated behind featureMajorHeadlinesPush. Made-with: Cursor --- .../highlight/HighlightCardOptions.spec.tsx | 109 +++++++++++++ .../cards/highlight/HighlightCardOptions.tsx | 96 +++++++++++ .../src/components/cards/highlight/common.tsx | 2 + .../EnableHighlightsAlerts.spec.tsx | 153 ++++++++++++++++++ .../highlights/EnableHighlightsAlerts.tsx | 118 ++++++++++++++ .../components/highlights/HighlightsPage.tsx | 2 + .../notifications/EmailNotificationsTab.tsx | 32 ++++ .../notifications/InAppNotificationsTab.tsx | 32 ++++ .../src/components/notifications/utils.ts | 2 + packages/shared/src/graphql/actions.ts | 1 + .../useMajorHeadlinesSubscription.ts | 115 +++++++++++++ packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/src/lib/log.ts | 7 + 13 files changed, 673 insertions(+) create mode 100644 packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx create mode 100644 packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx create mode 100644 packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx create mode 100644 packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx create mode 100644 packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts 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 00000000000..2233df3e952 --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -0,0 +1,109 @@ +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(); + +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 }), +})); + +const renderComponent = () => render(); + +describe('HighlightCardOptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAuth.mockReturnValue({ user: { id: '1' } }); + mockUseConditionalFeature.mockReturnValue({ value: true }); + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: false, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); + }); + + it('should render trigger button when feature is on and user is logged in', () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: 'Highlight options' }), + ).toBeInTheDocument(); + }); + + it('should not render for guests', () => { + mockUseAuth.mockReturnValue({ user: undefined }); + + renderComponent(); + + expect( + screen.queryByRole('button', { name: 'Highlight options' }), + ).not.toBeInTheDocument(); + }); + + it('should not render when feature is off', () => { + mockUseConditionalFeature.mockReturnValue({ value: false }); + + renderComponent(); + + expect( + screen.queryByRole('button', { name: 'Highlight options' }), + ).not.toBeInTheDocument(); + }); + + it('should show subscribe label when not subscribed and trigger subscribe on click', async () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Highlight options' })); + + const action = await screen.findByText('Get real-time alerts'); + fireEvent.click(action); + + await waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('feed_card'); + }); + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalledWith( + "You'll be the first to know when news breaks.", + ); + }); + }); + + it('should show unsubscribe label when subscribed and trigger unsubscribe on click', async () => { + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: true, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); + + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Highlight options' })); + + const action = await screen.findByText('Turn off real-time alerts'); + fireEvent.click(action); + + await waitFor(() => { + expect(mockUnsubscribe).toHaveBeenCalledWith('feed_card'); + }); + }); +}); 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 00000000000..ac52b41e225 --- /dev/null +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -0,0 +1,96 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../../dropdown/DropdownMenu'; +import { + BellAddIcon, + BellSubscribedIcon, + MenuIcon as RawMenuIcon, +} from '../../icons'; +import { MenuIcon } from '../../MenuIcon'; +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'; +import type { MenuItemProps } from '../../dropdown/common'; + +interface HighlightCardOptionsProps { + className?: string; +} + +const HighlightCardOptionsContent = ({ + className, +}: HighlightCardOptionsProps): ReactElement => { + const [open, setOpen] = useState(false); + const { displayToast } = useToastNotification(); + const { isSubscribed, subscribe, unsubscribe } = + useMajorHeadlinesSubscription(); + + const handleToggle = async () => { + 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."); + }; + + const options: MenuItemProps[] = [ + { + icon: ( + + ), + label: isSubscribed + ? 'Turn off real-time alerts' + : 'Get real-time alerts', + action: handleToggle, + }, + ]; + + return ( + + + + + + ); +}; + +export default EnableHighlightsAlerts; diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index 784d9d1cd61..f3616cfaa09 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 { user } = useAuthContext(); const { notificationSettings: ns, toggleSetting, @@ -27,9 +31,37 @@ const EmailNotificationsTab = (): ReactElement => { unsubscribeAllEmail, emailsDisabled, } = useNotificationSettings(); + const { value: isMajorHeadlinesEnabled } = useConditionalFeature({ + feature: featureMajorHeadlinesPush, + shouldEvaluate: !!user, + }); return (
+ {isMajorHeadlinesEnabled && ( + <> + + + Happening Now + + + + toggleSetting(NotificationType.MajorHeadlineAdded, 'email') + } + /> + + + + + )} Activity diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index 3a1df5eea21..aa98cae52e0 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -40,6 +40,9 @@ import NotificationCheckbox from './NotificationCheckbox'; import NotificationSwitch from './NotificationSwitch'; import NotificationGroupToggle from './NotificationToggle'; import ReadingReminderToggle from './ReadingReminderToggle'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureMajorHeadlinesPush } from '../../lib/featureManagement'; +import { useAuthContext } from '../../contexts/AuthContext'; const InAppNotificationsTab = (): ReactElement => { 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 cfc0bc6f080..13e1fb1bdfd 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 0ce5b75e3db..0dce4d08701 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -62,6 +62,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 00000000000..9f2ad28cec3 --- /dev/null +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -0,0 +1,115 @@ +import { useCallback, useMemo } 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 isInAppSubscribed = + settings?.inApp === NotificationPreferenceStatus.Subscribed; + const isEmailSubscribed = + settings?.email === NotificationPreferenceStatus.Subscribed; + const isSubscribed = isInAppSubscribed || isEmailSubscribed; + + 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, + }, + { + type: NotificationType.MajorHeadlineAdded, + channel: 'email', + 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, + }, + { + type: NotificationType.MajorHeadlineAdded, + channel: 'email', + status: NotificationPreferenceStatus.Muted, + }, + ]); + + logEvent({ + event_name: LogEvent.DisableMajorHeadlinesAlerts, + extra: JSON.stringify({ origin }), + }); + }, + [user, setNotificationStatusBulk, logEvent], + ); + + return useMemo( + () => ({ + isSubscribed, + isPushEnabled, + isLoading: isLoadingPreferences, + subscribe, + unsubscribe, + }), + [isSubscribed, isPushEnabled, isLoadingPreferences, subscribe, unsubscribe], + ); + }; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 08cb456228b..0bcc123e335 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, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 8d418c39ce3..108c7f6294f 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -123,6 +123,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', @@ -608,6 +612,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 { From 6b133bae0dc61b09ac8046a66c3ef3e9cacf2a75 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:20:27 +0300 Subject: [PATCH 02/12] fix: lint and dropdown menu test rendering Made-with: Cursor --- .../highlight/HighlightCardOptions.spec.tsx | 40 ++++++++++++------- .../cards/highlight/HighlightCardOptions.tsx | 12 ++---- .../useMajorHeadlinesSubscription.ts | 11 ++++- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx index 2233df3e952..1595286de91 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { HighlightCardOptions } from './HighlightCardOptions'; +import type { MenuItemProps } from '../../dropdown/common'; const mockSubscribe = jest.fn().mockResolvedValue(undefined); const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); @@ -17,17 +18,34 @@ jest.mock('../../../hooks/useConditionalFeature', () => ({ useConditionalFeature: () => mockUseConditionalFeature(), })); -jest.mock( - '../../../hooks/notifications/useMajorHeadlinesSubscription', - () => ({ - useMajorHeadlinesSubscription: () => mockUseMajorHeadlinesSubscription(), - }), -); +jest.mock('../../../hooks/notifications/useMajorHeadlinesSubscription', () => ({ + useMajorHeadlinesSubscription: () => mockUseMajorHeadlinesSubscription(), +})); jest.mock('../../../hooks/useToastNotification', () => ({ useToastNotification: () => ({ displayToast: mockDisplayToast }), })); +jest.mock('../../dropdown/DropdownMenu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => + children, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => ( +
+ {options.map(({ label, action }) => ( + + ))} +
+ ), +})); + const renderComponent = () => render(); describe('HighlightCardOptions', () => { @@ -73,10 +91,7 @@ describe('HighlightCardOptions', () => { it('should show subscribe label when not subscribed and trigger subscribe on click', async () => { renderComponent(); - fireEvent.click(screen.getByRole('button', { name: 'Highlight options' })); - - const action = await screen.findByText('Get real-time alerts'); - fireEvent.click(action); + fireEvent.click(screen.getByText('Get real-time alerts')); await waitFor(() => { expect(mockSubscribe).toHaveBeenCalledWith('feed_card'); @@ -97,10 +112,7 @@ describe('HighlightCardOptions', () => { renderComponent(); - fireEvent.click(screen.getByRole('button', { name: 'Highlight options' })); - - const action = await screen.findByText('Turn off real-time alerts'); - fireEvent.click(action); + fireEvent.click(screen.getByText('Turn off real-time alerts')); await waitFor(() => { expect(mockUnsubscribe).toHaveBeenCalledWith('feed_card'); diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index ac52b41e225..7a931a34239 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -45,9 +45,7 @@ const HighlightCardOptionsContent = ({ const options: MenuItemProps[] = [ { - icon: ( - - ), + icon: , label: isSubscribed ? 'Turn off real-time alerts' : 'Get real-time alerts', @@ -67,11 +65,9 @@ const HighlightCardOptionsContent = ({ aria-label="Highlight options" /> - {open && ( - - - - )} + + + ); }; diff --git a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts index 9f2ad28cec3..c9a03b5f244 100644 --- a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -39,7 +39,8 @@ export const useMajorHeadlinesSubscription = setNotificationStatusBulk, } = useNotificationSettings(); - const settings = notificationSettings?.[NotificationType.MajorHeadlineAdded]; + const settings = + notificationSettings?.[NotificationType.MajorHeadlineAdded]; const isInAppSubscribed = settings?.inApp === NotificationPreferenceStatus.Subscribed; const isEmailSubscribed = @@ -110,6 +111,12 @@ export const useMajorHeadlinesSubscription = subscribe, unsubscribe, }), - [isSubscribed, isPushEnabled, isLoadingPreferences, subscribe, unsubscribe], + [ + isSubscribed, + isPushEnabled, + isLoadingPreferences, + subscribe, + unsubscribe, + ], ); }; From e0ce388f8a8f6630f65cd44b7b4df9decd9079d7 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:52:46 +0300 Subject: [PATCH 03/12] fix: address PR review feedback - Only mutate inApp channel from CTAs (banner, card menu) to avoid overriding user's email preference set in settings. - Consume isLoading + local pending guard to prevent double-click mutations on banner and card menu. - Drop redundant useAuthContext optional chaining and unnecessary useMemo wrap on the hook return. Made-with: Cursor --- .../cards/highlight/HighlightCardOptions.tsx | 24 +++++++---- .../highlights/EnableHighlightsAlerts.tsx | 21 +++++++--- .../useMajorHeadlinesSubscription.ts | 40 +++++-------------- 3 files changed, 41 insertions(+), 44 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index 7a931a34239..d612ee91a8e 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -29,18 +29,27 @@ const HighlightCardOptionsContent = ({ className, }: HighlightCardOptionsProps): ReactElement => { const [open, setOpen] = useState(false); + const [isPending, setIsPending] = useState(false); const { displayToast } = useToastNotification(); - const { isSubscribed, subscribe, unsubscribe } = + const { isSubscribed, isLoading, subscribe, unsubscribe } = useMajorHeadlinesSubscription(); const handleToggle = async () => { - if (isSubscribed) { - await unsubscribe('feed_card'); - displayToast('Real-time alerts turned off.'); + if (isPending || isLoading) { return; } - await subscribe('feed_card'); - displayToast("You'll be the first to know when news breaks."); + 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."); + } finally { + setIsPending(false); + } }; const options: MenuItemProps[] = [ @@ -75,8 +84,7 @@ const HighlightCardOptionsContent = ({ export const HighlightCardOptions = ({ className, }: HighlightCardOptionsProps): ReactElement | null => { - const auth = useAuthContext(); - const user = auth?.user; + const { user } = useAuthContext(); const { value: isFeatureEnabled } = useConditionalFeature({ feature: featureMajorHeadlinesPush, shouldEvaluate: !!user, diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx index 54b3ec59c15..29afe6904db 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import CloseButton from '../CloseButton'; @@ -33,7 +33,9 @@ export const EnableHighlightsAlerts = ({ const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); - const { isSubscribed, subscribe } = useMajorHeadlinesSubscription(); + const { isSubscribed, isLoading, subscribe } = + useMajorHeadlinesSubscription(); + const [isPending, setIsPending] = useState(false); const { value: isFeatureEnabled } = useConditionalFeature({ feature: featureMajorHeadlinesPush, @@ -60,9 +62,17 @@ export const EnableHighlightsAlerts = ({ ); const handleEnable = useCallback(async () => { - await subscribe(ORIGIN); - displayToast("You'll be the first to know when news breaks."); - }, [subscribe, displayToast]); + if (isPending || isLoading) { + return; + } + setIsPending(true); + try { + await subscribe(ORIGIN); + displayToast("You'll be the first to know when news breaks."); + } finally { + setIsPending(false); + } + }, [isPending, isLoading, subscribe, displayToast]); const handleDismiss = useCallback(() => { completeAction(ActionType.DismissedMajorHeadlinesAlertsBanner); @@ -102,6 +112,7 @@ export const EnableHighlightsAlerts = ({ variant={ButtonVariant.Primary} size={ButtonSize.Small} onClick={handleEnable} + disabled={isPending || isLoading} > Turn on alerts diff --git a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts index c9a03b5f244..611a0d20346 100644 --- a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { NotificationType } from '../../components/notifications/utils'; import { NotificationPreferenceStatus } from '../../graphql/notifications'; import useNotificationSettings from './useNotificationSettings'; @@ -41,11 +41,8 @@ export const useMajorHeadlinesSubscription = const settings = notificationSettings?.[NotificationType.MajorHeadlineAdded]; - const isInAppSubscribed = + const isSubscribed = settings?.inApp === NotificationPreferenceStatus.Subscribed; - const isEmailSubscribed = - settings?.email === NotificationPreferenceStatus.Subscribed; - const isSubscribed = isInAppSubscribed || isEmailSubscribed; const subscribe = useCallback( async (origin: MajorHeadlinesOrigin) => { @@ -61,11 +58,6 @@ export const useMajorHeadlinesSubscription = channel: 'inApp', status: NotificationPreferenceStatus.Subscribed, }, - { - type: NotificationType.MajorHeadlineAdded, - channel: 'email', - status: NotificationPreferenceStatus.Subscribed, - }, ]); logEvent({ @@ -88,11 +80,6 @@ export const useMajorHeadlinesSubscription = channel: 'inApp', status: NotificationPreferenceStatus.Muted, }, - { - type: NotificationType.MajorHeadlineAdded, - channel: 'email', - status: NotificationPreferenceStatus.Muted, - }, ]); logEvent({ @@ -103,20 +90,11 @@ export const useMajorHeadlinesSubscription = [user, setNotificationStatusBulk, logEvent], ); - return useMemo( - () => ({ - isSubscribed, - isPushEnabled, - isLoading: isLoadingPreferences, - subscribe, - unsubscribe, - }), - [ - isSubscribed, - isPushEnabled, - isLoadingPreferences, - subscribe, - unsubscribe, - ], - ); + return { + isSubscribed, + isPushEnabled, + isLoading: isLoadingPreferences, + subscribe, + unsubscribe, + }; }; From 25b31edde244a40ca124e81f95a6219faa2f8447 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:57:55 +0300 Subject: [PATCH 04/12] fix: restore null-safe useAuthContext access in HighlightCardOptions Made-with: Cursor --- .../src/components/cards/highlight/HighlightCardOptions.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index d612ee91a8e..461862de579 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -84,7 +84,8 @@ const HighlightCardOptionsContent = ({ export const HighlightCardOptions = ({ className, }: HighlightCardOptionsProps): ReactElement | null => { - const { user } = useAuthContext(); + const auth = useAuthContext(); + const user = auth?.user; const { value: isFeatureEnabled } = useConditionalFeature({ feature: featureMajorHeadlinesPush, shouldEvaluate: !!user, From 4f71b969b0da1866dd8d2d39fd6f35e7e8b1a71c Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:13:44 +0300 Subject: [PATCH 05/12] feat: refine major headlines alerts UX - Reword "Major headlines" description to be channel-agnostic in notification settings (in-app + email tabs). - Show the 3-dots options on the highlight card only on hover, matching other card patterns; keep visible while the menu is open. - Add a "Settings" action button to the enable-alerts toast that deep links to /settings/notifications so users can adjust preferences. - Reuse the notifications-page push prompt visual on /highlights (header + browser image + cabbage CTA + top-right close) with copy tailored to major news, replacing the inline banner. Made-with: Cursor --- .../highlight/HighlightCardOptions.spec.tsx | 23 ++++ .../cards/highlight/HighlightCardOptions.tsx | 17 ++- .../EnableHighlightsAlerts.spec.tsx | 66 ++++++++--- .../highlights/EnableHighlightsAlerts.tsx | 109 +++++++++++++----- .../notifications/EmailNotificationsTab.tsx | 2 +- .../notifications/InAppNotificationsTab.tsx | 2 +- 6 files changed, 166 insertions(+), 53 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx index 1595286de91..8024126a0ea 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -9,6 +9,11 @@ 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(), @@ -99,10 +104,28 @@ describe('HighlightCardOptions', () => { 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.getByText('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 show unsubscribe label when subscribed and trigger unsubscribe on click', async () => { mockUseMajorHeadlinesSubscription.mockReturnValue({ isSubscribed: true, diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index 461862de579..ebfc83f575d 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -1,6 +1,7 @@ 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 { DropdownMenu, @@ -21,6 +22,8 @@ import { featureMajorHeadlinesPush } from '../../../lib/featureManagement'; import { useToastNotification } from '../../../hooks/useToastNotification'; import type { MenuItemProps } from '../../dropdown/common'; +const NOTIFICATION_SETTINGS_PATH = '/settings/notifications'; + interface HighlightCardOptionsProps { className?: string; } @@ -28,6 +31,7 @@ interface HighlightCardOptionsProps { const HighlightCardOptionsContent = ({ className, }: HighlightCardOptionsProps): ReactElement => { + const router = useRouter(); const [open, setOpen] = useState(false); const [isPending, setIsPending] = useState(false); const { displayToast } = useToastNotification(); @@ -46,7 +50,12 @@ const HighlightCardOptionsContent = ({ return; } await subscribe('feed_card'); - displayToast("You'll be the first to know when news breaks."); + displayToast("You'll be the first to know when news breaks.", { + action: { + copy: 'Settings', + onClick: () => router.push(NOTIFICATION_SETTINGS_PATH), + }, + }); } finally { setIsPending(false); } @@ -70,7 +79,11 @@ const HighlightCardOptionsContent = ({ variant={ButtonVariant.Tertiary} size={ButtonSize.Small} icon={} - className={classNames('my-auto', className)} + className={classNames( + 'my-auto', + !open && 'invisible group-hover:visible', + className, + )} aria-label="Highlight options" /> diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx index f87ebcf75a8..26862f9dd93 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx @@ -12,6 +12,16 @@ const mockDisplayToast = jest.fn(); const mockUseAuth = jest.fn(); const mockUseConditionalFeature = jest.fn(); const mockUseMajorHeadlinesSubscription = jest.fn(); +const mockRouterPush = jest.fn(); +const mockUsePushNotificationContext = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + +jest.mock('../../contexts/PushNotificationContext', () => ({ + usePushNotificationContext: () => mockUsePushNotificationContext(), +})); jest.mock('../../contexts/LogContext', () => ({ useLogContext: () => ({ logEvent: mockLogEvent }), @@ -50,19 +60,19 @@ describe('EnableHighlightsAlerts', () => { mockUseConditionalFeature.mockReturnValue({ value: true }); mockUseMajorHeadlinesSubscription.mockReturnValue({ isSubscribed: false, + isLoading: false, subscribe: mockSubscribe, unsubscribe: jest.fn(), }); mockCheckHasCompleted.mockReturnValue(false); + mockUsePushNotificationContext.mockReturnValue({ isSubscribed: false }); }); it('should render banner when feature is on, user is logged in, not subscribed and not dismissed', () => { renderComponent(); - expect( - screen.getByText('Get real-time alerts when news breaks'), - ).toBeInTheDocument(); - expect(screen.getByText('Turn on alerts')).toBeInTheDocument(); + expect(screen.getByText('Push notifications')).toBeInTheDocument(); + expect(screen.getByText('Enable notifications')).toBeInTheDocument(); }); it('should not render when feature is off', () => { @@ -70,9 +80,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect( - screen.queryByText('Get real-time alerts when news breaks'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); }); it('should not render for guests', () => { @@ -80,23 +88,20 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect( - screen.queryByText('Get real-time alerts when news breaks'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); }); it('should not render when already subscribed', () => { mockUseMajorHeadlinesSubscription.mockReturnValue({ isSubscribed: true, + isLoading: false, subscribe: mockSubscribe, unsubscribe: jest.fn(), }); renderComponent(); - expect( - screen.queryByText('Get real-time alerts when news breaks'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); }); it('should not render when dismissed', () => { @@ -104,9 +109,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect( - screen.queryByText('Get real-time alerts when news breaks'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); }); it('should log impression on render', () => { @@ -118,10 +121,10 @@ describe('EnableHighlightsAlerts', () => { }); }); - it('should subscribe and show toast on CTA click', async () => { + it('should subscribe and show toast with settings action on CTA click when push is not yet enabled', async () => { renderComponent(); - fireEvent.click(screen.getByText('Turn on alerts')); + fireEvent.click(screen.getByText('Enable notifications')); await waitFor(() => { expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); @@ -130,8 +133,35 @@ describe('EnableHighlightsAlerts', () => { await waitFor(() => { expect(mockDisplayToast).toHaveBeenCalledWith( "You'll be the first to know when news breaks.", + expect.objectContaining({ + action: expect.objectContaining({ copy: 'Settings' }), + }), ); }); + + const toastArgs = mockDisplayToast.mock.calls[0][1]; + toastArgs.action.onClick(); + expect(mockRouterPush).toHaveBeenCalledWith('/settings/notifications'); + }); + + it('should show enabled state inline when push was already granted', async () => { + mockUsePushNotificationContext.mockReturnValue({ isSubscribed: true }); + + renderComponent(); + + fireEvent.click(screen.getByText('Enable notifications')); + + await waitFor(() => { + expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); + }); + + await waitFor(() => { + expect( + screen.getByText('Push notifications successfully enabled'), + ).toBeInTheDocument(); + }); + + expect(mockDisplayToast).not.toHaveBeenCalled(); }); it('should complete dismiss action and log dismiss on close', async () => { diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx index 29afe6904db..b6dad8c02a4 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx @@ -1,14 +1,19 @@ import type { ReactElement } from 'react'; import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { useRouter } from 'next/router'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import CloseButton from '../CloseButton'; import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; -import { BellIcon } from '../icons'; + cloudinaryNotificationsBrowser, + cloudinaryNotificationsBrowserEnabled, +} from '../../lib/image'; +import { VIcon } from '../icons'; import { useAuthContext } from '../../contexts/AuthContext'; import { useActions } from '../../hooks/useActions'; import { ActionType } from '../../graphql/actions'; @@ -19,23 +24,28 @@ import { useLogContext } from '../../contexts/LogContext'; import useLogEventOnce from '../../hooks/log/useLogEventOnce'; import { LogEvent } from '../../lib/log'; import { useToastNotification } from '../../hooks/useToastNotification'; +import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; interface EnableHighlightsAlertsProps { className?: string; } const ORIGIN = 'highlights_page'; +const NOTIFICATION_SETTINGS_PATH = '/settings/notifications'; export const EnableHighlightsAlerts = ({ className, }: EnableHighlightsAlertsProps): ReactElement | null => { + const router = useRouter(); const { user } = useAuthContext(); + const { isSubscribed: isPushEnabled } = usePushNotificationContext(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); const { isSubscribed, isLoading, subscribe } = useMajorHeadlinesSubscription(); const [isPending, setIsPending] = useState(false); + const [acceptedJustNow, setAcceptedJustNow] = useState(false); const { value: isFeatureEnabled } = useConditionalFeature({ feature: featureMajorHeadlinesPush, @@ -68,11 +78,20 @@ export const EnableHighlightsAlerts = ({ setIsPending(true); try { await subscribe(ORIGIN); - displayToast("You'll be the first to know when news breaks."); + if (isPushEnabled) { + setAcceptedJustNow(true); + return; + } + displayToast("You'll be the first to know when news breaks.", { + action: { + copy: 'Settings', + onClick: () => router.push(NOTIFICATION_SETTINGS_PATH), + }, + }); } finally { setIsPending(false); } - }, [isPending, isLoading, subscribe, displayToast]); + }, [isPending, isLoading, subscribe, isPushEnabled, displayToast, router]); const handleDismiss = useCallback(() => { completeAction(ActionType.DismissedMajorHeadlinesAlertsBanner); @@ -89,36 +108,64 @@ export const EnableHighlightsAlerts = ({ return (
-
- + + {acceptedJustNow && } + {`Push notifications${acceptedJustNow ? ' successfully enabled' : ''}`} + +
+

+ {acceptedJustNow ? ( + <> + You can change your{' '} + {' '} + anytime. + + ) : ( + 'Be the first to know when something major happens in the developer world.' + )} +

+
-
- - Get real-time alerts when news breaks - - - Be the first to know when major headlines drop. - +
+ {!acceptedJustNow && ( + + )}
- diff --git a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx index 498dea96eb9..c25c5f189fb 100644 --- a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx @@ -48,7 +48,7 @@ const EmailNotificationsTab = (): ReactElement => { { Date: Sun, 26 Apr 2026 18:15:13 +0300 Subject: [PATCH 06/12] feat: expose bell toggle directly on highlight card Replace the 3-dots dropdown with a single bell icon button that toggles major headline alerts on click. Keeps the hover-only visibility so it matches sibling card actions, and uses a tooltip for the accessible label. Made-with: Cursor --- .../highlight/HighlightCardOptions.spec.tsx | 52 +++++++-------- .../cards/highlight/HighlightCardOptions.tsx | 63 +++++++------------ 2 files changed, 45 insertions(+), 70 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx index 8024126a0ea..d7ffe74270c 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { HighlightCardOptions } from './HighlightCardOptions'; -import type { MenuItemProps } from '../../dropdown/common'; const mockSubscribe = jest.fn().mockResolvedValue(undefined); const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); @@ -31,24 +30,8 @@ jest.mock('../../../hooks/useToastNotification', () => ({ useToastNotification: () => ({ displayToast: mockDisplayToast }), })); -jest.mock('../../dropdown/DropdownMenu', () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => - children, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => ( -
- {options.map(({ label, action }) => ( - - ))} -
- ), +jest.mock('../../tooltip/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); const renderComponent = () => render(); @@ -60,16 +43,17 @@ describe('HighlightCardOptions', () => { mockUseConditionalFeature.mockReturnValue({ value: true }); mockUseMajorHeadlinesSubscription.mockReturnValue({ isSubscribed: false, + isLoading: false, subscribe: mockSubscribe, unsubscribe: mockUnsubscribe, }); }); - it('should render trigger button when feature is on and user is logged in', () => { + it('should render bell button when feature is on and user is logged in', () => { renderComponent(); expect( - screen.getByRole('button', { name: 'Highlight options' }), + screen.getByRole('button', { name: 'Get real-time alerts' }), ).toBeInTheDocument(); }); @@ -79,7 +63,7 @@ describe('HighlightCardOptions', () => { renderComponent(); expect( - screen.queryByRole('button', { name: 'Highlight options' }), + screen.queryByRole('button', { name: 'Get real-time alerts' }), ).not.toBeInTheDocument(); }); @@ -89,14 +73,16 @@ describe('HighlightCardOptions', () => { renderComponent(); expect( - screen.queryByRole('button', { name: 'Highlight options' }), + screen.queryByRole('button', { name: 'Get real-time alerts' }), ).not.toBeInTheDocument(); }); - it('should show subscribe label when not subscribed and trigger subscribe on click', async () => { + it('should subscribe and show toast with settings action when not subscribed', async () => { renderComponent(); - fireEvent.click(screen.getByText('Get real-time alerts')); + fireEvent.click( + screen.getByRole('button', { name: 'Get real-time alerts' }), + ); await waitFor(() => { expect(mockSubscribe).toHaveBeenCalledWith('feed_card'); @@ -114,7 +100,9 @@ describe('HighlightCardOptions', () => { it('should navigate to notification settings when toast action is clicked', async () => { renderComponent(); - fireEvent.click(screen.getByText('Get real-time alerts')); + fireEvent.click( + screen.getByRole('button', { name: 'Get real-time alerts' }), + ); await waitFor(() => { expect(mockDisplayToast).toHaveBeenCalled(); @@ -126,19 +114,27 @@ describe('HighlightCardOptions', () => { expect(mockRouterPush).toHaveBeenCalledWith('/settings/notifications'); }); - it('should show unsubscribe label when subscribed and trigger unsubscribe on click', async () => { + it('should unsubscribe when subscribed', async () => { mockUseMajorHeadlinesSubscription.mockReturnValue({ isSubscribed: true, + isLoading: false, subscribe: mockSubscribe, unsubscribe: mockUnsubscribe, }); renderComponent(); - fireEvent.click(screen.getByText('Turn off real-time alerts')); + 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 index ebfc83f575d..2a406a7e614 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -3,24 +3,13 @@ import React, { useState } from 'react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuOptions, - DropdownMenuTrigger, -} from '../../dropdown/DropdownMenu'; -import { - BellAddIcon, - BellSubscribedIcon, - MenuIcon as RawMenuIcon, -} from '../../icons'; -import { MenuIcon } from '../../MenuIcon'; +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'; -import type { MenuItemProps } from '../../dropdown/common'; const NOTIFICATION_SETTINGS_PATH = '/settings/notifications'; @@ -32,7 +21,6 @@ const HighlightCardOptionsContent = ({ className, }: HighlightCardOptionsProps): ReactElement => { const router = useRouter(); - const [open, setOpen] = useState(false); const [isPending, setIsPending] = useState(false); const { displayToast } = useToastNotification(); const { isSubscribed, isLoading, subscribe, unsubscribe } = @@ -61,36 +49,27 @@ const HighlightCardOptionsContent = ({ } }; - const options: MenuItemProps[] = [ - { - icon: , - label: isSubscribed - ? 'Turn off real-time alerts' - : 'Get real-time alerts', - action: handleToggle, - }, - ]; + const label = isSubscribed + ? 'Turn off real-time alerts' + : 'Get real-time alerts'; + const Icon = isSubscribed ? BellSubscribedIcon : BellAddIcon; return ( - - - diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index fd64d9bae78..f55a1cc44e8 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -43,6 +43,7 @@ import ReadingReminderToggle from './ReadingReminderToggle'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { featureMajorHeadlinesPush } from '../../lib/featureManagement'; import { useAuthContext } from '../../contexts/AuthContext'; +import MajorHeadlinesNotificationSection from './MajorHeadlinesNotificationSection'; const InAppNotificationsTab = (): ReactElement => { const { logEvent } = useLogContext(); @@ -127,25 +128,7 @@ const InAppNotificationsTab = (): ReactElement => {
{isMajorHeadlinesEnabled && ( <> - - - Happening Now - - - - toggleSetting(NotificationType.MajorHeadlineAdded, 'inApp') - } - /> - - + )} diff --git a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx new file mode 100644 index 00000000000..e8977057666 --- /dev/null +++ b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import MajorHeadlinesNotificationSection from './MajorHeadlinesNotificationSection'; +import { HighlightSignificance } from '../../graphql/highlights'; + +const mockSetChannelPreference = jest.fn().mockResolvedValue(undefined); +const mockUseChannelHighlightPreferences = jest.fn(); + +jest.mock('../../hooks/notifications/useChannelHighlightPreferences', () => ({ + useChannelHighlightPreferences: () => mockUseChannelHighlightPreferences(), +})); + +const baseChannels = [ + { + channel: 'tech', + displayName: 'Tech', + viewerMinSignificance: null, + }, + { + channel: 'ai', + displayName: 'AI', + viewerMinSignificance: HighlightSignificance.Major, + }, +]; + +const setupHook = (overrides = {}) => { + mockUseChannelHighlightPreferences.mockReturnValue({ + channels: baseChannels, + isLoading: false, + isPending: false, + setChannelPreference: mockSetChannelPreference, + getMinSignificance: (channel: string) => + baseChannels.find((c) => c.channel === channel)?.viewerMinSignificance ?? + null, + ...overrides, + }); +}; + +const renderSection = () => { + const client = new QueryClient(); + return render( + + + , + ); +}; + +describe('MajorHeadlinesNotificationSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupHook(); + }); + + it('renders one row per active channel', () => { + renderSection(); + + expect(screen.getByRole('checkbox', { name: 'Tech' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'AI' })).toBeInTheDocument(); + }); + + it('renders nothing while loading', () => { + setupHook({ isLoading: true }); + + renderSection(); + + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + }); + + it('renders nothing when there are no channels', () => { + setupHook({ + channels: [], + getMinSignificance: () => null, + }); + + renderSection(); + + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + }); + + it('subscribes to a channel at Major+ when toggled on', async () => { + renderSection(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Tech' })); + + await waitFor(() => { + expect(mockSetChannelPreference).toHaveBeenCalledWith( + 'tech', + HighlightSignificance.Major, + ); + }); + }); + + it('clears the threshold when toggled off', async () => { + renderSection(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'AI' })); + + await waitFor(() => { + expect(mockSetChannelPreference).toHaveBeenCalledWith('ai', null); + }); + }); +}); diff --git a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx new file mode 100644 index 00000000000..1ef38af8415 --- /dev/null +++ b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Switch } from '../fields/Switch'; +import { Dropdown } from '../fields/Dropdown'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import { useChannelHighlightPreferences } from '../../hooks/notifications/useChannelHighlightPreferences'; +import { HighlightSignificance } from '../../graphql/highlights'; +import { NotificationContainer, NotificationSection } from './utils'; + +const SIGNIFICANCE_ORDER: HighlightSignificance[] = [ + HighlightSignificance.Breaking, + HighlightSignificance.Major, + HighlightSignificance.Notable, + HighlightSignificance.Routine, +]; + +const SIGNIFICANCE_LABELS: Record = { + [HighlightSignificance.Breaking]: 'Breaking only', + [HighlightSignificance.Major]: 'Major and above', + [HighlightSignificance.Notable]: 'Notable and above', + [HighlightSignificance.Routine]: 'Routine and above (everything)', +}; + +const DEFAULT_SIGNIFICANCE = HighlightSignificance.Major; + +const MajorHeadlinesNotificationSection = (): ReactElement | null => { + const { + channels, + isLoading, + isPending, + setChannelPreference, + getMinSignificance, + } = useChannelHighlightPreferences(); + + if (isLoading || channels.length === 0) { + return null; + } + + return ( + + + Happening Now + + + Choose how often you hear from each channel. Lower significance levels + mean more notifications. + + + {channels.map((channel) => { + const current = getMinSignificance(channel.channel); + const isOn = !!current; + const inputId = `major-headlines-${channel.channel}`; + const selectedIndex = current + ? SIGNIFICANCE_ORDER.indexOf(current as HighlightSignificance) + : -1; + const dropdownOptions = SIGNIFICANCE_ORDER.map( + (level) => SIGNIFICANCE_LABELS[level], + ); + + return ( +
+
+ + {channel.displayName} + + + setChannelPreference( + channel.channel, + isOn ? null : DEFAULT_SIGNIFICANCE, + ) + } + /> +
+ {isOn && ( + = 0 ? selectedIndex : 1} + options={dropdownOptions} + buttonSize={ButtonSize.Small} + buttonVariant={ButtonVariant.Float} + disabled={isPending} + onChange={(_label, index) => + setChannelPreference( + channel.channel, + SIGNIFICANCE_ORDER[index], + ) + } + /> + )} +
+ ); + })} +
+
+ ); +}; + +export default MajorHeadlinesNotificationSection; diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 13bf196b201..86708eff786 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -152,12 +152,61 @@ export interface ChannelDigestConfiguration { }; } +export enum HighlightSignificance { + Breaking = 'BREAKING', + Major = 'MAJOR', + Notable = 'NOTABLE', + Routine = 'ROUTINE', +} + export interface ChannelConfiguration { channel: string; displayName: string; digest?: ChannelDigestConfiguration | null; + viewerMinSignificance?: HighlightSignificance | null; +} + +export interface ChannelConfigurationsData { + channelConfigurations: ChannelConfiguration[]; } +export const CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY = [ + 'channel-highlight-preferences', +]; + +export const CHANNEL_HIGHLIGHT_PREFERENCES_QUERY = gql` + query ChannelHighlightPreferences { + channelConfigurations { + channel + displayName + viewerMinSignificance + } + } +`; + +export const channelHighlightPreferencesQueryOptions = () => ({ + queryKey: CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + queryFn: () => + gqlClient.request( + CHANNEL_HIGHLIGHT_PREFERENCES_QUERY, + ), + staleTime: ONE_MINUTE, +}); + +export const SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION = gql` + mutation SetChannelHighlightPreference( + $channel: String! + $minSignificance: HighlightSignificance + ) { + setChannelHighlightPreference( + channel: $channel + minSignificance: $minSignificance + ) { + _ + } + } +`; + export interface HighlightsPageData { majorHeadlines: Connection; channelConfigurations: ChannelConfiguration[]; @@ -179,6 +228,7 @@ export const HIGHLIGHTS_PAGE_QUERY = gql` channelConfigurations { channel displayName + viewerMinSignificance digest { frequency source { diff --git a/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts b/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts new file mode 100644 index 00000000000..dd0dd731d6b --- /dev/null +++ b/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts @@ -0,0 +1,146 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import { + CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION, + channelHighlightPreferencesQueryOptions, +} from '../../graphql/highlights'; +import type { + ChannelConfiguration, + ChannelConfigurationsData, + HighlightSignificance, +} from '../../graphql/highlights'; +import { useAuthContext } from '../../contexts/AuthContext'; + +type UseChannelHighlightPreferencesResult = { + channels: ChannelConfiguration[]; + isLoading: boolean; + isPending: boolean; + getMinSignificance: ( + channel: string, + ) => HighlightSignificance | null | undefined; + isChannelSubscribed: (channel: string) => boolean; + isAnyChannelSubscribed: boolean; + setChannelPreference: ( + channel: string, + minSignificance: HighlightSignificance | null, + ) => Promise; + subscribeAll: (minSignificance: HighlightSignificance) => Promise; +}; + +export const useChannelHighlightPreferences = + (): UseChannelHighlightPreferencesResult => { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + const [pendingCount, setPendingCount] = useState(0); + + const { data, isLoading } = useQuery({ + ...channelHighlightPreferencesQueryOptions(), + enabled: !!user, + }); + + const channels = useMemo(() => data?.channelConfigurations ?? [], [data]); + + const mutation = useMutation({ + mutationFn: (vars: { + channel: string; + minSignificance: HighlightSignificance | null; + }) => + gqlClient.request(SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION, { + channel: vars.channel, + minSignificance: vars.minSignificance, + }), + onMutate: async ({ channel, minSignificance }) => { + await queryClient.cancelQueries({ + queryKey: CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + }); + const previous = queryClient.getQueryData( + CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + ); + if (previous) { + queryClient.setQueryData( + CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + { + ...previous, + channelConfigurations: previous.channelConfigurations.map( + (config) => + config.channel === channel + ? { ...config, viewerMinSignificance: minSignificance } + : config, + ), + }, + ); + } + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + queryClient.setQueryData( + CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, + context.previous, + ); + } + }, + }); + + const setChannelPreference = useCallback( + async ( + channel: string, + minSignificance: HighlightSignificance | null, + ) => { + if (!user) { + return; + } + setPendingCount((count) => count + 1); + try { + await mutation.mutateAsync({ channel, minSignificance }); + } finally { + setPendingCount((count) => Math.max(0, count - 1)); + } + }, + [user, mutation], + ); + + const subscribeAll = useCallback( + async (minSignificance: HighlightSignificance) => { + if (!user || channels.length === 0) { + return; + } + await Promise.all( + channels.map((config) => + setChannelPreference(config.channel, minSignificance), + ), + ); + }, + [user, channels, setChannelPreference], + ); + + const getMinSignificance = useCallback( + (channel: string) => + channels.find((config) => config.channel === channel) + ?.viewerMinSignificance ?? null, + [channels], + ); + + const isChannelSubscribed = useCallback( + (channel: string) => !!getMinSignificance(channel), + [getMinSignificance], + ); + + const isAnyChannelSubscribed = useMemo( + () => channels.some((config) => !!config.viewerMinSignificance), + [channels], + ); + + return { + channels, + isLoading, + isPending: pendingCount > 0, + getMinSignificance, + isChannelSubscribed, + isAnyChannelSubscribed, + setChannelPreference, + subscribeAll, + }; + }; diff --git a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts index 611a0d20346..e775084c721 100644 --- a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -1,7 +1,9 @@ import { useCallback } from 'react'; -import { NotificationType } from '../../components/notifications/utils'; -import { NotificationPreferenceStatus } from '../../graphql/notifications'; -import useNotificationSettings from './useNotificationSettings'; +import type { + HighlightSignificance, + ChannelConfiguration, +} from '../../graphql/highlights'; +import { useChannelHighlightPreferences } from './useChannelHighlightPreferences'; import { usePushNotificationMutation } from './usePushNotificationMutation'; import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -11,11 +13,28 @@ import { LogEvent, NotificationPromptSource } from '../../lib/log'; type MajorHeadlinesOrigin = 'settings' | 'highlights_page' | 'feed_card'; type UseMajorHeadlinesSubscriptionResult = { - isSubscribed: boolean; + channels: ChannelConfiguration[]; + isAnyChannelSubscribed: boolean; + isChannelSubscribed: (channel: string) => boolean; + getMinSignificance: ( + channel: string, + ) => HighlightSignificance | null | undefined; isPushEnabled: boolean; isLoading: boolean; - subscribe: (origin: MajorHeadlinesOrigin) => Promise; - unsubscribe: (origin: MajorHeadlinesOrigin) => Promise; + isPending: boolean; + subscribeChannel: ( + channel: string, + minSignificance: HighlightSignificance, + origin: MajorHeadlinesOrigin, + ) => Promise; + unsubscribeChannel: ( + channel: string, + origin: MajorHeadlinesOrigin, + ) => Promise; + subscribeAll: ( + minSignificance: HighlightSignificance, + origin: MajorHeadlinesOrigin, + ) => Promise; }; const ORIGIN_TO_PROMPT_SOURCE: Record< @@ -34,67 +53,85 @@ export const useMajorHeadlinesSubscription = 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) => { + channels, + isLoading, + isPending, + getMinSignificance, + isChannelSubscribed, + isAnyChannelSubscribed, + setChannelPreference, + subscribeAll: subscribeAllChannels, + } = useChannelHighlightPreferences(); + + const subscribeChannel = useCallback( + async ( + channel: string, + minSignificance: HighlightSignificance, + origin: MajorHeadlinesOrigin, + ) => { if (!user) { return; } await onEnablePush(ORIGIN_TO_PROMPT_SOURCE[origin]); - setNotificationStatusBulk([ - { - type: NotificationType.MajorHeadlineAdded, - channel: 'inApp', - status: NotificationPreferenceStatus.Subscribed, - }, - ]); + await setChannelPreference(channel, minSignificance); logEvent({ event_name: LogEvent.EnableMajorHeadlinesAlerts, - extra: JSON.stringify({ origin }), + extra: JSON.stringify({ origin, channel, minSignificance }), }); }, - [user, onEnablePush, setNotificationStatusBulk, logEvent], + [user, onEnablePush, setChannelPreference, logEvent], ); - const unsubscribe = useCallback( - async (origin: MajorHeadlinesOrigin) => { + const unsubscribeChannel = useCallback( + async (channel: string, origin: MajorHeadlinesOrigin) => { if (!user) { return; } - setNotificationStatusBulk([ - { - type: NotificationType.MajorHeadlineAdded, - channel: 'inApp', - status: NotificationPreferenceStatus.Muted, - }, - ]); + await setChannelPreference(channel, null); logEvent({ event_name: LogEvent.DisableMajorHeadlinesAlerts, - extra: JSON.stringify({ origin }), + extra: JSON.stringify({ origin, channel }), + }); + }, + [user, setChannelPreference, logEvent], + ); + + const subscribeAll = useCallback( + async ( + minSignificance: HighlightSignificance, + origin: MajorHeadlinesOrigin, + ) => { + if (!user) { + return; + } + + await onEnablePush(ORIGIN_TO_PROMPT_SOURCE[origin]); + + await subscribeAllChannels(minSignificance); + + logEvent({ + event_name: LogEvent.EnableMajorHeadlinesAlerts, + extra: JSON.stringify({ origin, scope: 'all', minSignificance }), }); }, - [user, setNotificationStatusBulk, logEvent], + [user, onEnablePush, subscribeAllChannels, logEvent], ); return { - isSubscribed, + channels, + isAnyChannelSubscribed, + isChannelSubscribed, + getMinSignificance, isPushEnabled, - isLoading: isLoadingPreferences, - subscribe, - unsubscribe, + isLoading, + isPending, + subscribeChannel, + unsubscribeChannel, + subscribeAll, }; }; From 5c369a1f33928767094af3ee0649d7d5429065b9 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:32:23 +0300 Subject: [PATCH 10/12] Revert "feat: per-topic headline significance preferences" This reverts commit 987480fc22131f7f68f366778d43d15e1191cb6f. --- .../highlight/HighlightCardOptions.spec.tsx | 50 +++--- .../cards/highlight/HighlightCardOptions.tsx | 37 ++--- .../src/components/cards/highlight/common.tsx | 5 +- .../EnableHighlightsAlerts.spec.tsx | 43 +++--- .../highlights/EnableHighlightsAlerts.tsx | 27 ++-- .../notifications/InAppNotificationsTab.tsx | 21 ++- ...MajorHeadlinesNotificationSection.spec.tsx | 103 ------------ .../MajorHeadlinesNotificationSection.tsx | 115 -------------- packages/shared/src/graphql/highlights.ts | 50 ------ .../useChannelHighlightPreferences.ts | 146 ------------------ .../useMajorHeadlinesSubscription.ts | 121 +++++---------- 11 files changed, 120 insertions(+), 598 deletions(-) delete mode 100644 packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx delete mode 100644 packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx delete mode 100644 packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx index 0a9a9a78b58..d7ffe74270c 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { HighlightCardOptions } from './HighlightCardOptions'; -import { HighlightSignificance } from '../../../graphql/highlights'; -const mockSubscribeChannel = jest.fn().mockResolvedValue(undefined); -const mockUnsubscribeChannel = jest.fn().mockResolvedValue(undefined); -const mockIsChannelSubscribed = jest.fn(); +const mockSubscribe = jest.fn().mockResolvedValue(undefined); +const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); const mockDisplayToast = jest.fn(); const mockUseAuth = jest.fn(); const mockUseConditionalFeature = jest.fn(); @@ -36,25 +34,22 @@ jest.mock('../../tooltip/Tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); -const renderComponent = (channel = 'tech') => - render(); +const renderComponent = () => render(); describe('HighlightCardOptions', () => { beforeEach(() => { jest.clearAllMocks(); mockUseAuth.mockReturnValue({ user: { id: '1' } }); mockUseConditionalFeature.mockReturnValue({ value: true }); - mockIsChannelSubscribed.mockReturnValue(false); mockUseMajorHeadlinesSubscription.mockReturnValue({ - isChannelSubscribed: mockIsChannelSubscribed, + isSubscribed: false, isLoading: false, - isPending: false, - subscribeChannel: mockSubscribeChannel, - unsubscribeChannel: mockUnsubscribeChannel, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, }); }); - it('should render bell button when feature is on, user is logged in and channel is provided', () => { + it('should render bell button when feature is on and user is logged in', () => { renderComponent(); expect( @@ -82,27 +77,15 @@ describe('HighlightCardOptions', () => { ).not.toBeInTheDocument(); }); - it('should not render when channel is missing', () => { - render(); - - expect( - screen.queryByRole('button', { name: 'Get real-time alerts' }), - ).not.toBeInTheDocument(); - }); - - it('should subscribe channel at Major+ and show toast with settings action when not subscribed', async () => { - renderComponent('tech'); + 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(mockSubscribeChannel).toHaveBeenCalledWith( - 'tech', - HighlightSignificance.Major, - 'feed_card', - ); + expect(mockSubscribe).toHaveBeenCalledWith('feed_card'); }); await waitFor(() => { expect(mockDisplayToast).toHaveBeenCalledWith( @@ -131,17 +114,22 @@ describe('HighlightCardOptions', () => { expect(mockRouterPush).toHaveBeenCalledWith('/settings/notifications'); }); - it('should unsubscribe when channel is already subscribed', async () => { - mockIsChannelSubscribed.mockReturnValue(true); + it('should unsubscribe when subscribed', async () => { + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: true, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: mockUnsubscribe, + }); - renderComponent('tech'); + renderComponent(); fireEvent.click( screen.getByRole('button', { name: 'Turn off real-time alerts' }), ); await waitFor(() => { - expect(mockUnsubscribeChannel).toHaveBeenCalledWith('tech', 'feed_card'); + expect(mockUnsubscribe).toHaveBeenCalledWith('feed_card'); }); await waitFor(() => { expect(mockDisplayToast).toHaveBeenCalledWith( diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx index 92fe3a1e330..a0d9416689b 100644 --- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx @@ -10,44 +10,34 @@ import { useMajorHeadlinesSubscription } from '../../../hooks/notifications/useM import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { featureMajorHeadlinesPush } from '../../../lib/featureManagement'; import { useToastNotification } from '../../../hooks/useToastNotification'; -import { HighlightSignificance } from '../../../graphql/highlights'; const NOTIFICATION_SETTINGS_PATH = '/settings/notifications'; interface HighlightCardOptionsProps { - channel?: string; className?: string; } const HighlightCardOptionsContent = ({ - channel, className, -}: HighlightCardOptionsProps & { channel: string }): ReactElement => { +}: HighlightCardOptionsProps): ReactElement => { const router = useRouter(); - const [isToggling, setIsToggling] = useState(false); + const [isPending, setIsPending] = useState(false); const { displayToast } = useToastNotification(); - const { - isChannelSubscribed, - isLoading, - isPending, - subscribeChannel, - unsubscribeChannel, - } = useMajorHeadlinesSubscription(); - - const isSubscribed = isChannelSubscribed(channel); + const { isSubscribed, isLoading, subscribe, unsubscribe } = + useMajorHeadlinesSubscription(); const handleToggle = async () => { - if (isToggling || isLoading || isPending) { + if (isPending || isLoading) { return; } - setIsToggling(true); + setIsPending(true); try { if (isSubscribed) { - await unsubscribeChannel(channel, 'feed_card'); + await unsubscribe('feed_card'); displayToast('Real-time alerts turned off.'); return; } - await subscribeChannel(channel, HighlightSignificance.Major, 'feed_card'); + await subscribe('feed_card'); displayToast("You'll be the first to know when news breaks.", { action: { copy: 'Settings', @@ -55,7 +45,7 @@ const HighlightCardOptionsContent = ({ }, }); } finally { - setIsToggling(false); + setIsPending(false); } }; @@ -77,14 +67,13 @@ const HighlightCardOptionsContent = ({ )} aria-label={label} onClick={handleToggle} - disabled={isToggling || isLoading || isPending} + disabled={isPending || isLoading} /> ); }; export const HighlightCardOptions = ({ - channel, className, }: HighlightCardOptionsProps): ReactElement | null => { const auth = useAuthContext(); @@ -94,13 +83,11 @@ export const HighlightCardOptions = ({ shouldEvaluate: !!user, }); - if (!isFeatureEnabled || !user || !channel) { + if (!isFeatureEnabled || !user) { return null; } - return ( - - ); + return ; }; export default HighlightCardOptions; diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index ef0478eadc9..b1f5d93dfa3 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -80,10 +80,7 @@ export const HighlightCardContent = ({ > Happening Now - +
{highlights.map((highlight, index) => ( diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx index b6c945a4133..26862f9dd93 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx @@ -3,10 +3,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { EnableHighlightsAlerts } from './EnableHighlightsAlerts'; import { LogEvent } from '../../lib/log'; import { ActionType } from '../../graphql/actions'; -import { HighlightSignificance } from '../../graphql/highlights'; const mockLogEvent = jest.fn(); -const mockSubscribeAll = jest.fn().mockResolvedValue(undefined); +const mockSubscribe = jest.fn().mockResolvedValue(undefined); const mockCompleteAction = jest.fn().mockResolvedValue(undefined); const mockCheckHasCompleted = jest.fn(); const mockDisplayToast = jest.fn(); @@ -54,25 +53,22 @@ jest.mock('../../hooks/useToastNotification', () => ({ const renderComponent = () => render(); -const defaultHookReturn = (overrides = {}) => ({ - isAnyChannelSubscribed: false, - isLoading: false, - isPending: false, - subscribeAll: mockSubscribeAll, - ...overrides, -}); - describe('EnableHighlightsAlerts', () => { beforeEach(() => { jest.clearAllMocks(); mockUseAuth.mockReturnValue({ user: { id: '1' } }); mockUseConditionalFeature.mockReturnValue({ value: true }); - mockUseMajorHeadlinesSubscription.mockReturnValue(defaultHookReturn()); + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: false, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: jest.fn(), + }); mockCheckHasCompleted.mockReturnValue(false); mockUsePushNotificationContext.mockReturnValue({ isSubscribed: false }); }); - it('should render banner when feature is on, user is logged in, no channels subscribed and not dismissed', () => { + it('should render banner when feature is on, user is logged in, not subscribed and not dismissed', () => { renderComponent(); expect(screen.getByText('Push notifications')).toBeInTheDocument(); @@ -95,10 +91,13 @@ describe('EnableHighlightsAlerts', () => { expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); }); - it('should not render when any channel is already subscribed', () => { - mockUseMajorHeadlinesSubscription.mockReturnValue( - defaultHookReturn({ isAnyChannelSubscribed: true }), - ); + it('should not render when already subscribed', () => { + mockUseMajorHeadlinesSubscription.mockReturnValue({ + isSubscribed: true, + isLoading: false, + subscribe: mockSubscribe, + unsubscribe: jest.fn(), + }); renderComponent(); @@ -122,16 +121,13 @@ describe('EnableHighlightsAlerts', () => { }); }); - it('should subscribe all channels at Major+ and show toast on CTA click when push is not yet enabled', async () => { + it('should subscribe and show toast with settings action on CTA click when push is not yet enabled', async () => { renderComponent(); fireEvent.click(screen.getByText('Enable notifications')); await waitFor(() => { - expect(mockSubscribeAll).toHaveBeenCalledWith( - HighlightSignificance.Major, - 'highlights_page', - ); + expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); }); await waitFor(() => { @@ -156,10 +152,7 @@ describe('EnableHighlightsAlerts', () => { fireEvent.click(screen.getByText('Enable notifications')); await waitFor(() => { - expect(mockSubscribeAll).toHaveBeenCalledWith( - HighlightSignificance.Major, - 'highlights_page', - ); + expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); }); await waitFor(() => { diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx index 9c7b29d7392..b6dad8c02a4 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx @@ -25,7 +25,6 @@ import useLogEventOnce from '../../hooks/log/useLogEventOnce'; import { LogEvent } from '../../lib/log'; import { useToastNotification } from '../../hooks/useToastNotification'; import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; -import { HighlightSignificance } from '../../graphql/highlights'; interface EnableHighlightsAlertsProps { className?: string; @@ -43,9 +42,9 @@ export const EnableHighlightsAlerts = ({ const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const { logEvent } = useLogContext(); const { displayToast } = useToastNotification(); - const { isAnyChannelSubscribed, isLoading, isPending, subscribeAll } = + const { isSubscribed, isLoading, subscribe } = useMajorHeadlinesSubscription(); - const [isSubmitting, setIsSubmitting] = useState(false); + const [isPending, setIsPending] = useState(false); const [acceptedJustNow, setAcceptedJustNow] = useState(false); const { value: isFeatureEnabled } = useConditionalFeature({ @@ -61,7 +60,7 @@ export const EnableHighlightsAlerts = ({ isFeatureEnabled && !!user && isActionsFetched && - !isAnyChannelSubscribed && + !isSubscribed && !isDismissed; useLogEventOnce( @@ -73,12 +72,12 @@ export const EnableHighlightsAlerts = ({ ); const handleEnable = useCallback(async () => { - if (isSubmitting || isLoading || isPending) { + if (isPending || isLoading) { return; } - setIsSubmitting(true); + setIsPending(true); try { - await subscribeAll(HighlightSignificance.Major, ORIGIN); + await subscribe(ORIGIN); if (isPushEnabled) { setAcceptedJustNow(true); return; @@ -90,17 +89,9 @@ export const EnableHighlightsAlerts = ({ }, }); } finally { - setIsSubmitting(false); + setIsPending(false); } - }, [ - isSubmitting, - isLoading, - isPending, - subscribeAll, - isPushEnabled, - displayToast, - router, - ]); + }, [isPending, isLoading, subscribe, isPushEnabled, displayToast, router]); const handleDismiss = useCallback(() => { completeAction(ActionType.DismissedMajorHeadlinesAlertsBanner); @@ -165,7 +156,7 @@ export const EnableHighlightsAlerts = ({ color={ButtonColor.Cabbage} className="mr-4" onClick={handleEnable} - disabled={isSubmitting || isLoading || isPending} + disabled={isPending || isLoading} > Enable notifications diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index f55a1cc44e8..fd64d9bae78 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -43,7 +43,6 @@ import ReadingReminderToggle from './ReadingReminderToggle'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { featureMajorHeadlinesPush } from '../../lib/featureManagement'; import { useAuthContext } from '../../contexts/AuthContext'; -import MajorHeadlinesNotificationSection from './MajorHeadlinesNotificationSection'; const InAppNotificationsTab = (): ReactElement => { const { logEvent } = useLogContext(); @@ -128,7 +127,25 @@ const InAppNotificationsTab = (): ReactElement => {
{isMajorHeadlinesEnabled && ( <> - + + + Happening Now + + + + toggleSetting(NotificationType.MajorHeadlineAdded, 'inApp') + } + /> + + )} diff --git a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx deleted file mode 100644 index e8977057666..00000000000 --- a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import MajorHeadlinesNotificationSection from './MajorHeadlinesNotificationSection'; -import { HighlightSignificance } from '../../graphql/highlights'; - -const mockSetChannelPreference = jest.fn().mockResolvedValue(undefined); -const mockUseChannelHighlightPreferences = jest.fn(); - -jest.mock('../../hooks/notifications/useChannelHighlightPreferences', () => ({ - useChannelHighlightPreferences: () => mockUseChannelHighlightPreferences(), -})); - -const baseChannels = [ - { - channel: 'tech', - displayName: 'Tech', - viewerMinSignificance: null, - }, - { - channel: 'ai', - displayName: 'AI', - viewerMinSignificance: HighlightSignificance.Major, - }, -]; - -const setupHook = (overrides = {}) => { - mockUseChannelHighlightPreferences.mockReturnValue({ - channels: baseChannels, - isLoading: false, - isPending: false, - setChannelPreference: mockSetChannelPreference, - getMinSignificance: (channel: string) => - baseChannels.find((c) => c.channel === channel)?.viewerMinSignificance ?? - null, - ...overrides, - }); -}; - -const renderSection = () => { - const client = new QueryClient(); - return render( - - - , - ); -}; - -describe('MajorHeadlinesNotificationSection', () => { - beforeEach(() => { - jest.clearAllMocks(); - setupHook(); - }); - - it('renders one row per active channel', () => { - renderSection(); - - expect(screen.getByRole('checkbox', { name: 'Tech' })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: 'AI' })).toBeInTheDocument(); - }); - - it('renders nothing while loading', () => { - setupHook({ isLoading: true }); - - renderSection(); - - expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); - }); - - it('renders nothing when there are no channels', () => { - setupHook({ - channels: [], - getMinSignificance: () => null, - }); - - renderSection(); - - expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); - }); - - it('subscribes to a channel at Major+ when toggled on', async () => { - renderSection(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Tech' })); - - await waitFor(() => { - expect(mockSetChannelPreference).toHaveBeenCalledWith( - 'tech', - HighlightSignificance.Major, - ); - }); - }); - - it('clears the threshold when toggled off', async () => { - renderSection(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'AI' })); - - await waitFor(() => { - expect(mockSetChannelPreference).toHaveBeenCalledWith('ai', null); - }); - }); -}); diff --git a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx b/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx deleted file mode 100644 index 1ef38af8415..00000000000 --- a/packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; -import { Switch } from '../fields/Switch'; -import { Dropdown } from '../fields/Dropdown'; -import { ButtonSize, ButtonVariant } from '../buttons/common'; -import { useChannelHighlightPreferences } from '../../hooks/notifications/useChannelHighlightPreferences'; -import { HighlightSignificance } from '../../graphql/highlights'; -import { NotificationContainer, NotificationSection } from './utils'; - -const SIGNIFICANCE_ORDER: HighlightSignificance[] = [ - HighlightSignificance.Breaking, - HighlightSignificance.Major, - HighlightSignificance.Notable, - HighlightSignificance.Routine, -]; - -const SIGNIFICANCE_LABELS: Record = { - [HighlightSignificance.Breaking]: 'Breaking only', - [HighlightSignificance.Major]: 'Major and above', - [HighlightSignificance.Notable]: 'Notable and above', - [HighlightSignificance.Routine]: 'Routine and above (everything)', -}; - -const DEFAULT_SIGNIFICANCE = HighlightSignificance.Major; - -const MajorHeadlinesNotificationSection = (): ReactElement | null => { - const { - channels, - isLoading, - isPending, - setChannelPreference, - getMinSignificance, - } = useChannelHighlightPreferences(); - - if (isLoading || channels.length === 0) { - return null; - } - - return ( - - - Happening Now - - - Choose how often you hear from each channel. Lower significance levels - mean more notifications. - - - {channels.map((channel) => { - const current = getMinSignificance(channel.channel); - const isOn = !!current; - const inputId = `major-headlines-${channel.channel}`; - const selectedIndex = current - ? SIGNIFICANCE_ORDER.indexOf(current as HighlightSignificance) - : -1; - const dropdownOptions = SIGNIFICANCE_ORDER.map( - (level) => SIGNIFICANCE_LABELS[level], - ); - - return ( -
-
- - {channel.displayName} - - - setChannelPreference( - channel.channel, - isOn ? null : DEFAULT_SIGNIFICANCE, - ) - } - /> -
- {isOn && ( - = 0 ? selectedIndex : 1} - options={dropdownOptions} - buttonSize={ButtonSize.Small} - buttonVariant={ButtonVariant.Float} - disabled={isPending} - onChange={(_label, index) => - setChannelPreference( - channel.channel, - SIGNIFICANCE_ORDER[index], - ) - } - /> - )} -
- ); - })} -
-
- ); -}; - -export default MajorHeadlinesNotificationSection; diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 86708eff786..13bf196b201 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -152,61 +152,12 @@ export interface ChannelDigestConfiguration { }; } -export enum HighlightSignificance { - Breaking = 'BREAKING', - Major = 'MAJOR', - Notable = 'NOTABLE', - Routine = 'ROUTINE', -} - export interface ChannelConfiguration { channel: string; displayName: string; digest?: ChannelDigestConfiguration | null; - viewerMinSignificance?: HighlightSignificance | null; -} - -export interface ChannelConfigurationsData { - channelConfigurations: ChannelConfiguration[]; } -export const CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY = [ - 'channel-highlight-preferences', -]; - -export const CHANNEL_HIGHLIGHT_PREFERENCES_QUERY = gql` - query ChannelHighlightPreferences { - channelConfigurations { - channel - displayName - viewerMinSignificance - } - } -`; - -export const channelHighlightPreferencesQueryOptions = () => ({ - queryKey: CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - queryFn: () => - gqlClient.request( - CHANNEL_HIGHLIGHT_PREFERENCES_QUERY, - ), - staleTime: ONE_MINUTE, -}); - -export const SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION = gql` - mutation SetChannelHighlightPreference( - $channel: String! - $minSignificance: HighlightSignificance - ) { - setChannelHighlightPreference( - channel: $channel - minSignificance: $minSignificance - ) { - _ - } - } -`; - export interface HighlightsPageData { majorHeadlines: Connection; channelConfigurations: ChannelConfiguration[]; @@ -228,7 +179,6 @@ export const HIGHLIGHTS_PAGE_QUERY = gql` channelConfigurations { channel displayName - viewerMinSignificance digest { frequency source { diff --git a/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts b/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts deleted file mode 100644 index dd0dd731d6b..00000000000 --- a/packages/shared/src/hooks/notifications/useChannelHighlightPreferences.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { gqlClient } from '../../graphql/common'; -import { - CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION, - channelHighlightPreferencesQueryOptions, -} from '../../graphql/highlights'; -import type { - ChannelConfiguration, - ChannelConfigurationsData, - HighlightSignificance, -} from '../../graphql/highlights'; -import { useAuthContext } from '../../contexts/AuthContext'; - -type UseChannelHighlightPreferencesResult = { - channels: ChannelConfiguration[]; - isLoading: boolean; - isPending: boolean; - getMinSignificance: ( - channel: string, - ) => HighlightSignificance | null | undefined; - isChannelSubscribed: (channel: string) => boolean; - isAnyChannelSubscribed: boolean; - setChannelPreference: ( - channel: string, - minSignificance: HighlightSignificance | null, - ) => Promise; - subscribeAll: (minSignificance: HighlightSignificance) => Promise; -}; - -export const useChannelHighlightPreferences = - (): UseChannelHighlightPreferencesResult => { - const { user } = useAuthContext(); - const queryClient = useQueryClient(); - const [pendingCount, setPendingCount] = useState(0); - - const { data, isLoading } = useQuery({ - ...channelHighlightPreferencesQueryOptions(), - enabled: !!user, - }); - - const channels = useMemo(() => data?.channelConfigurations ?? [], [data]); - - const mutation = useMutation({ - mutationFn: (vars: { - channel: string; - minSignificance: HighlightSignificance | null; - }) => - gqlClient.request(SET_CHANNEL_HIGHLIGHT_PREFERENCE_MUTATION, { - channel: vars.channel, - minSignificance: vars.minSignificance, - }), - onMutate: async ({ channel, minSignificance }) => { - await queryClient.cancelQueries({ - queryKey: CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - }); - const previous = queryClient.getQueryData( - CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - ); - if (previous) { - queryClient.setQueryData( - CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - { - ...previous, - channelConfigurations: previous.channelConfigurations.map( - (config) => - config.channel === channel - ? { ...config, viewerMinSignificance: minSignificance } - : config, - ), - }, - ); - } - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous) { - queryClient.setQueryData( - CHANNEL_HIGHLIGHT_PREFERENCES_QUERY_KEY, - context.previous, - ); - } - }, - }); - - const setChannelPreference = useCallback( - async ( - channel: string, - minSignificance: HighlightSignificance | null, - ) => { - if (!user) { - return; - } - setPendingCount((count) => count + 1); - try { - await mutation.mutateAsync({ channel, minSignificance }); - } finally { - setPendingCount((count) => Math.max(0, count - 1)); - } - }, - [user, mutation], - ); - - const subscribeAll = useCallback( - async (minSignificance: HighlightSignificance) => { - if (!user || channels.length === 0) { - return; - } - await Promise.all( - channels.map((config) => - setChannelPreference(config.channel, minSignificance), - ), - ); - }, - [user, channels, setChannelPreference], - ); - - const getMinSignificance = useCallback( - (channel: string) => - channels.find((config) => config.channel === channel) - ?.viewerMinSignificance ?? null, - [channels], - ); - - const isChannelSubscribed = useCallback( - (channel: string) => !!getMinSignificance(channel), - [getMinSignificance], - ); - - const isAnyChannelSubscribed = useMemo( - () => channels.some((config) => !!config.viewerMinSignificance), - [channels], - ); - - return { - channels, - isLoading, - isPending: pendingCount > 0, - getMinSignificance, - isChannelSubscribed, - isAnyChannelSubscribed, - setChannelPreference, - subscribeAll, - }; - }; diff --git a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts index e775084c721..611a0d20346 100644 --- a/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts +++ b/packages/shared/src/hooks/notifications/useMajorHeadlinesSubscription.ts @@ -1,9 +1,7 @@ import { useCallback } from 'react'; -import type { - HighlightSignificance, - ChannelConfiguration, -} from '../../graphql/highlights'; -import { useChannelHighlightPreferences } from './useChannelHighlightPreferences'; +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'; @@ -13,28 +11,11 @@ import { LogEvent, NotificationPromptSource } from '../../lib/log'; type MajorHeadlinesOrigin = 'settings' | 'highlights_page' | 'feed_card'; type UseMajorHeadlinesSubscriptionResult = { - channels: ChannelConfiguration[]; - isAnyChannelSubscribed: boolean; - isChannelSubscribed: (channel: string) => boolean; - getMinSignificance: ( - channel: string, - ) => HighlightSignificance | null | undefined; + isSubscribed: boolean; isPushEnabled: boolean; isLoading: boolean; - isPending: boolean; - subscribeChannel: ( - channel: string, - minSignificance: HighlightSignificance, - origin: MajorHeadlinesOrigin, - ) => Promise; - unsubscribeChannel: ( - channel: string, - origin: MajorHeadlinesOrigin, - ) => Promise; - subscribeAll: ( - minSignificance: HighlightSignificance, - origin: MajorHeadlinesOrigin, - ) => Promise; + subscribe: (origin: MajorHeadlinesOrigin) => Promise; + unsubscribe: (origin: MajorHeadlinesOrigin) => Promise; }; const ORIGIN_TO_PROMPT_SOURCE: Record< @@ -53,85 +34,67 @@ export const useMajorHeadlinesSubscription = const { isSubscribed: isPushEnabled } = usePushNotificationContext(); const { onEnablePush } = usePushNotificationMutation(); const { - channels, - isLoading, - isPending, - getMinSignificance, - isChannelSubscribed, - isAnyChannelSubscribed, - setChannelPreference, - subscribeAll: subscribeAllChannels, - } = useChannelHighlightPreferences(); - - const subscribeChannel = useCallback( - async ( - channel: string, - minSignificance: HighlightSignificance, - origin: MajorHeadlinesOrigin, - ) => { + 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]); - await setChannelPreference(channel, minSignificance); + setNotificationStatusBulk([ + { + type: NotificationType.MajorHeadlineAdded, + channel: 'inApp', + status: NotificationPreferenceStatus.Subscribed, + }, + ]); logEvent({ event_name: LogEvent.EnableMajorHeadlinesAlerts, - extra: JSON.stringify({ origin, channel, minSignificance }), + extra: JSON.stringify({ origin }), }); }, - [user, onEnablePush, setChannelPreference, logEvent], + [user, onEnablePush, setNotificationStatusBulk, logEvent], ); - const unsubscribeChannel = useCallback( - async (channel: string, origin: MajorHeadlinesOrigin) => { + const unsubscribe = useCallback( + async (origin: MajorHeadlinesOrigin) => { if (!user) { return; } - await setChannelPreference(channel, null); + setNotificationStatusBulk([ + { + type: NotificationType.MajorHeadlineAdded, + channel: 'inApp', + status: NotificationPreferenceStatus.Muted, + }, + ]); logEvent({ event_name: LogEvent.DisableMajorHeadlinesAlerts, - extra: JSON.stringify({ origin, channel }), - }); - }, - [user, setChannelPreference, logEvent], - ); - - const subscribeAll = useCallback( - async ( - minSignificance: HighlightSignificance, - origin: MajorHeadlinesOrigin, - ) => { - if (!user) { - return; - } - - await onEnablePush(ORIGIN_TO_PROMPT_SOURCE[origin]); - - await subscribeAllChannels(minSignificance); - - logEvent({ - event_name: LogEvent.EnableMajorHeadlinesAlerts, - extra: JSON.stringify({ origin, scope: 'all', minSignificance }), + extra: JSON.stringify({ origin }), }); }, - [user, onEnablePush, subscribeAllChannels, logEvent], + [user, setNotificationStatusBulk, logEvent], ); return { - channels, - isAnyChannelSubscribed, - isChannelSubscribed, - getMinSignificance, + isSubscribed, isPushEnabled, - isLoading, - isPending, - subscribeChannel, - unsubscribeChannel, - subscribeAll, + isLoading: isLoadingPreferences, + subscribe, + unsubscribe, }; }; From 9cd07d7ee0ba5f31b3984db788b8acd2e6b9c4d7 Mon Sep 17 00:00:00 2001 From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:07:50 +0300 Subject: [PATCH 11/12] chore: refine breaking news copy - Rename "Major headlines" to "Breaking news" in the settings row. - Sharper, more intent-based banner copy on /highlights: title -> "Never miss a major headline" / "You're in the loop" CTA -> "Notify me". Made-with: Cursor --- .../EnableHighlightsAlerts.spec.tsx | 22 +++++++++---------- .../highlights/EnableHighlightsAlerts.tsx | 6 +++-- .../notifications/InAppNotificationsTab.tsx | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx index 26862f9dd93..78a424e5ab1 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx @@ -71,8 +71,10 @@ describe('EnableHighlightsAlerts', () => { it('should render banner when feature is on, user is logged in, not subscribed and not dismissed', () => { renderComponent(); - expect(screen.getByText('Push notifications')).toBeInTheDocument(); - expect(screen.getByText('Enable notifications')).toBeInTheDocument(); + expect( + screen.getByText('Never miss a major headline'), + ).toBeInTheDocument(); + expect(screen.getByText('Notify me')).toBeInTheDocument(); }); it('should not render when feature is off', () => { @@ -80,7 +82,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); + expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); }); it('should not render for guests', () => { @@ -88,7 +90,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); + expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); }); it('should not render when already subscribed', () => { @@ -101,7 +103,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); + expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); }); it('should not render when dismissed', () => { @@ -109,7 +111,7 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Push notifications')).not.toBeInTheDocument(); + expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); }); it('should log impression on render', () => { @@ -124,7 +126,7 @@ describe('EnableHighlightsAlerts', () => { it('should subscribe and show toast with settings action on CTA click when push is not yet enabled', async () => { renderComponent(); - fireEvent.click(screen.getByText('Enable notifications')); + fireEvent.click(screen.getByText('Notify me')); await waitFor(() => { expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); @@ -149,16 +151,14 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - fireEvent.click(screen.getByText('Enable notifications')); + fireEvent.click(screen.getByText('Notify me')); await waitFor(() => { expect(mockSubscribe).toHaveBeenCalledWith('highlights_page'); }); await waitFor(() => { - expect( - screen.getByText('Push notifications successfully enabled'), - ).toBeInTheDocument(); + expect(screen.getByText("You're in the loop")).toBeInTheDocument(); }); expect(mockDisplayToast).not.toHaveBeenCalled(); diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx index b6dad8c02a4..d651df8e4a2 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx @@ -114,7 +114,9 @@ export const EnableHighlightsAlerts = ({ > {acceptedJustNow && } - {`Push notifications${acceptedJustNow ? ' successfully enabled' : ''}`} + {acceptedJustNow + ? "You're in the loop" + : 'Never miss a major headline'}

@@ -158,7 +160,7 @@ export const EnableHighlightsAlerts = ({ onClick={handleEnable} disabled={isPending || isLoading} > - Enable notifications + Notify me )}

diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index fd64d9bae78..63716e3bb32 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -134,7 +134,7 @@ const InAppNotificationsTab = (): ReactElement => { Date: Mon, 27 Apr 2026 10:16:15 +0300 Subject: [PATCH 12/12] chore: fix prettier formatting Made-with: Cursor --- .../EnableHighlightsAlerts.spec.tsx | 20 ++++++++++++------- .../highlights/EnableHighlightsAlerts.tsx | 4 +--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx index 78a424e5ab1..76a28fa3f45 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx @@ -71,9 +71,7 @@ describe('EnableHighlightsAlerts', () => { it('should render banner when feature is on, user is logged in, not subscribed and not dismissed', () => { renderComponent(); - expect( - screen.getByText('Never miss a major headline'), - ).toBeInTheDocument(); + expect(screen.getByText('Never miss a major headline')).toBeInTheDocument(); expect(screen.getByText('Notify me')).toBeInTheDocument(); }); @@ -82,7 +80,9 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); + expect( + screen.queryByText('Never miss a major headline'), + ).not.toBeInTheDocument(); }); it('should not render for guests', () => { @@ -90,7 +90,9 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); + expect( + screen.queryByText('Never miss a major headline'), + ).not.toBeInTheDocument(); }); it('should not render when already subscribed', () => { @@ -103,7 +105,9 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); + expect( + screen.queryByText('Never miss a major headline'), + ).not.toBeInTheDocument(); }); it('should not render when dismissed', () => { @@ -111,7 +115,9 @@ describe('EnableHighlightsAlerts', () => { renderComponent(); - expect(screen.queryByText('Never miss a major headline')).not.toBeInTheDocument(); + expect( + screen.queryByText('Never miss a major headline'), + ).not.toBeInTheDocument(); }); it('should log impression on render', () => { diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx index d651df8e4a2..4055f226933 100644 --- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx +++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx @@ -114,9 +114,7 @@ export const EnableHighlightsAlerts = ({ > {acceptedJustNow && } - {acceptedJustNow - ? "You're in the loop" - : 'Never miss a major headline'} + {acceptedJustNow ? "You're in the loop" : 'Never miss a major headline'}