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 (
+
+
+ }
+ className={classNames('my-auto', className)}
+ aria-label="Highlight options"
+ />
+
+ {open && (
+
+
+
+ )}
+
+ );
+};
+
+export const HighlightCardOptions = ({
+ className,
+}: HighlightCardOptionsProps): ReactElement | null => {
+ const auth = useAuthContext();
+ const user = auth?.user;
+ const { value: isFeatureEnabled } = useConditionalFeature({
+ feature: featureMajorHeadlinesPush,
+ shouldEvaluate: !!user,
+ });
+
+ if (!isFeatureEnabled || !user) {
+ return null;
+ }
+
+ 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 5560f764291..b1f5d93dfa3 100644
--- a/packages/shared/src/components/cards/highlight/common.tsx
+++ b/packages/shared/src/components/cards/highlight/common.tsx
@@ -5,6 +5,7 @@ import type { PostHighlight } from '../../../graphql/highlights';
import { webappUrl } from '../../../lib/constants';
import { RelativeTime } from '../../utilities/RelativeTime';
import Link from '../../utilities/Link';
+import { HighlightCardOptions } from './HighlightCardOptions';
export interface HighlightCardProps {
highlights: PostHighlight[];
@@ -79,6 +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
new file mode 100644
index 00000000000..f87ebcf75a8
--- /dev/null
+++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { EnableHighlightsAlerts } from './EnableHighlightsAlerts';
+import { LogEvent } from '../../lib/log';
+import { ActionType } from '../../graphql/actions';
+
+const mockLogEvent = jest.fn();
+const mockSubscribe = jest.fn().mockResolvedValue(undefined);
+const mockCompleteAction = jest.fn().mockResolvedValue(undefined);
+const mockCheckHasCompleted = jest.fn();
+const mockDisplayToast = jest.fn();
+const mockUseAuth = jest.fn();
+const mockUseConditionalFeature = jest.fn();
+const mockUseMajorHeadlinesSubscription = jest.fn();
+
+jest.mock('../../contexts/LogContext', () => ({
+ useLogContext: () => ({ logEvent: mockLogEvent }),
+}));
+
+jest.mock('../../contexts/AuthContext', () => ({
+ useAuthContext: () => mockUseAuth(),
+}));
+
+jest.mock('../../hooks/useActions', () => ({
+ useActions: () => ({
+ checkHasCompleted: mockCheckHasCompleted,
+ completeAction: mockCompleteAction,
+ isActionsFetched: true,
+ }),
+}));
+
+jest.mock('../../hooks/useConditionalFeature', () => ({
+ useConditionalFeature: () => mockUseConditionalFeature(),
+}));
+
+jest.mock('../../hooks/notifications/useMajorHeadlinesSubscription', () => ({
+ useMajorHeadlinesSubscription: () => mockUseMajorHeadlinesSubscription(),
+}));
+
+jest.mock('../../hooks/useToastNotification', () => ({
+ useToastNotification: () => ({ displayToast: mockDisplayToast }),
+}));
+
+const renderComponent = () => render(
);
+
+describe('EnableHighlightsAlerts', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseAuth.mockReturnValue({ user: { id: '1' } });
+ mockUseConditionalFeature.mockReturnValue({ value: true });
+ mockUseMajorHeadlinesSubscription.mockReturnValue({
+ isSubscribed: false,
+ subscribe: mockSubscribe,
+ unsubscribe: jest.fn(),
+ });
+ mockCheckHasCompleted.mockReturnValue(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();
+ });
+
+ it('should not render when feature is off', () => {
+ mockUseConditionalFeature.mockReturnValue({ value: false });
+
+ renderComponent();
+
+ expect(
+ screen.queryByText('Get real-time alerts when news breaks'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not render for guests', () => {
+ mockUseAuth.mockReturnValue({ user: undefined });
+
+ renderComponent();
+
+ expect(
+ screen.queryByText('Get real-time alerts when news breaks'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not render when already subscribed', () => {
+ mockUseMajorHeadlinesSubscription.mockReturnValue({
+ isSubscribed: true,
+ subscribe: mockSubscribe,
+ unsubscribe: jest.fn(),
+ });
+
+ renderComponent();
+
+ expect(
+ screen.queryByText('Get real-time alerts when news breaks'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not render when dismissed', () => {
+ mockCheckHasCompleted.mockReturnValue(true);
+
+ renderComponent();
+
+ expect(
+ screen.queryByText('Get real-time alerts when news breaks'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should log impression on render', () => {
+ renderComponent();
+
+ expect(mockLogEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.ImpressionMajorHeadlinesAlertsBanner,
+ extra: JSON.stringify({ origin: 'highlights_page' }),
+ });
+ });
+
+ it('should subscribe and show toast on CTA click', async () => {
+ renderComponent();
+
+ fireEvent.click(screen.getByText('Turn on alerts'));
+
+ await waitFor(() => {
+ expect(mockSubscribe).toHaveBeenCalledWith('highlights_page');
+ });
+
+ await waitFor(() => {
+ expect(mockDisplayToast).toHaveBeenCalledWith(
+ "You'll be the first to know when news breaks.",
+ );
+ });
+ });
+
+ it('should complete dismiss action and log dismiss on close', async () => {
+ renderComponent();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
+
+ expect(mockLogEvent).toHaveBeenCalledWith({
+ event_name: LogEvent.DismissMajorHeadlinesAlertsBanner,
+ extra: JSON.stringify({ origin: 'highlights_page' }),
+ });
+
+ await waitFor(() => {
+ expect(mockCompleteAction).toHaveBeenCalledWith(
+ ActionType.DismissedMajorHeadlinesAlertsBanner,
+ );
+ });
+ });
+});
diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx
new file mode 100644
index 00000000000..54b3ec59c15
--- /dev/null
+++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx
@@ -0,0 +1,118 @@
+import type { ReactElement } from 'react';
+import React, { useCallback } from 'react';
+import classNames from 'classnames';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import CloseButton from '../CloseButton';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../typography/Typography';
+import { BellIcon } from '../icons';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useActions } from '../../hooks/useActions';
+import { ActionType } from '../../graphql/actions';
+import { useMajorHeadlinesSubscription } from '../../hooks/notifications/useMajorHeadlinesSubscription';
+import { useConditionalFeature } from '../../hooks/useConditionalFeature';
+import { featureMajorHeadlinesPush } from '../../lib/featureManagement';
+import { useLogContext } from '../../contexts/LogContext';
+import useLogEventOnce from '../../hooks/log/useLogEventOnce';
+import { LogEvent } from '../../lib/log';
+import { useToastNotification } from '../../hooks/useToastNotification';
+
+interface EnableHighlightsAlertsProps {
+ className?: string;
+}
+
+const ORIGIN = 'highlights_page';
+
+export const EnableHighlightsAlerts = ({
+ className,
+}: EnableHighlightsAlertsProps): ReactElement | null => {
+ const { user } = useAuthContext();
+ const { checkHasCompleted, completeAction, isActionsFetched } = useActions();
+ const { logEvent } = useLogContext();
+ const { displayToast } = useToastNotification();
+ const { isSubscribed, subscribe } = useMajorHeadlinesSubscription();
+
+ const { value: isFeatureEnabled } = useConditionalFeature({
+ feature: featureMajorHeadlinesPush,
+ shouldEvaluate: !!user,
+ });
+
+ const isDismissed = checkHasCompleted(
+ ActionType.DismissedMajorHeadlinesAlertsBanner,
+ );
+
+ const shouldRender =
+ isFeatureEnabled &&
+ !!user &&
+ isActionsFetched &&
+ !isSubscribed &&
+ !isDismissed;
+
+ useLogEventOnce(
+ () => ({
+ event_name: LogEvent.ImpressionMajorHeadlinesAlertsBanner,
+ extra: JSON.stringify({ origin: ORIGIN }),
+ }),
+ { condition: shouldRender },
+ );
+
+ const handleEnable = useCallback(async () => {
+ await subscribe(ORIGIN);
+ displayToast("You'll be the first to know when news breaks.");
+ }, [subscribe, displayToast]);
+
+ const handleDismiss = useCallback(() => {
+ completeAction(ActionType.DismissedMajorHeadlinesAlertsBanner);
+ logEvent({
+ event_name: LogEvent.DismissMajorHeadlinesAlertsBanner,
+ extra: JSON.stringify({ origin: ORIGIN }),
+ });
+ }, [completeAction, logEvent]);
+
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ Get real-time alerts when news breaks
+
+
+ Be the first to know when major headlines drop.
+
+
+
+ Turn on alerts
+
+
+
+ );
+};
+
+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 }) => (
+
+ {label}
+
+ ))}
+
+ ),
+}));
+
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{' '}
+ router.push(NOTIFICATION_SETTINGS_PATH)}
+ >
+ notification settings
+ {' '}
+ 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 && (
+
+ Enable notifications
+
+ )}
-
- Turn on alerts
-
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 }) => (
-
- {label}
-
- ))}
-
- ),
+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 (
-
-
- }
- className={classNames(
- 'my-auto',
- !open && 'invisible group-hover:visible',
- className,
- )}
- aria-label="Highlight options"
- />
-
-
-
-
-
+
+ }
+ className={classNames(
+ 'my-auto invisible group-hover:visible',
+ className,
+ )}
+ aria-label={label}
+ onClick={handleToggle}
+ disabled={isPending || isLoading}
+ />
+
);
};
From 77dc0bf1bc25457321bdbbe30361ad16cced9615 Mon Sep 17 00:00:00 2001
From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com>
Date: Sun, 26 Apr 2026 18:21:05 +0300
Subject: [PATCH 07/12] chore: fix tailwind class order on bell button
---
.../src/components/cards/highlight/HighlightCardOptions.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
index 2a406a7e614..a0d9416689b 100644
--- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
+++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
@@ -62,7 +62,7 @@ const HighlightCardOptionsContent = ({
size={ButtonSize.Small}
icon={ }
className={classNames(
- 'my-auto invisible group-hover:visible',
+ 'invisible my-auto group-hover:visible',
className,
)}
aria-label={label}
From f7bc87160e0f1cb60116d6324afc36f9dd6f4c66 Mon Sep 17 00:00:00 2001
From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com>
Date: Sun, 26 Apr 2026 23:14:41 +0300
Subject: [PATCH 08/12] feat: remove major headlines email setting
---
.../notifications/EmailNotificationsTab.tsx | 32 -------------------
1 file changed, 32 deletions(-)
diff --git a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx
index c25c5f189fb..e71392501d8 100644
--- a/packages/shared/src/components/notifications/EmailNotificationsTab.tsx
+++ b/packages/shared/src/components/notifications/EmailNotificationsTab.tsx
@@ -17,12 +17,8 @@ import PersonalizedDigest from './PersonalizedDigest';
import NotificationCheckbox from './NotificationCheckbox';
import NotificationSwitch from './NotificationSwitch';
import NotificationGroupToggle from './NotificationToggle';
-import { useConditionalFeature } from '../../hooks/useConditionalFeature';
-import { featureMajorHeadlinesPush } from '../../lib/featureManagement';
-import { useAuthContext } from '../../contexts/AuthContext';
const EmailNotificationsTab = (): ReactElement => {
- const { user } = useAuthContext();
const {
notificationSettings: ns,
toggleSetting,
@@ -31,37 +27,9 @@ const EmailNotificationsTab = (): ReactElement => {
unsubscribeAllEmail,
emailsDisabled,
} = useNotificationSettings();
- const { value: isMajorHeadlinesEnabled } = useConditionalFeature({
- feature: featureMajorHeadlinesPush,
- shouldEvaluate: !!user,
- });
return (
- {isMajorHeadlinesEnabled && (
- <>
-
-
- Happening Now
-
-
-
- toggleSetting(NotificationType.MajorHeadlineAdded, 'email')
- }
- />
-
-
-
- >
- )}
Activity
From 987480fc22131f7f68f366778d43d15e1191cb6f Mon Sep 17 00:00:00 2001
From: Nimrod Kramer <41470823+nimrodkra@users.noreply.github.com>
Date: Sun, 26 Apr 2026 23:46:07 +0300
Subject: [PATCH 09/12] feat: per-topic headline significance preferences
Settings tab now renders one row per active channel (switch + threshold
dropdown: Breaking only / Major and above / Notable and above / Routine
and above). Bell on highlight cards subscribes that channel at Major+.
Banner subscribes all active channels at Major+ via subscribeAll.
Made-with: Cursor
---
.../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, 598 insertions(+), 120 deletions(-)
create mode 100644 packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.spec.tsx
create mode 100644 packages/shared/src/components/notifications/MajorHeadlinesNotificationSection.tsx
create 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 d7ffe74270c..0a9a9a78b58 100644
--- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx
+++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx
@@ -1,9 +1,11 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { HighlightCardOptions } from './HighlightCardOptions';
+import { HighlightSignificance } from '../../../graphql/highlights';
-const mockSubscribe = jest.fn().mockResolvedValue(undefined);
-const mockUnsubscribe = jest.fn().mockResolvedValue(undefined);
+const mockSubscribeChannel = jest.fn().mockResolvedValue(undefined);
+const mockUnsubscribeChannel = jest.fn().mockResolvedValue(undefined);
+const mockIsChannelSubscribed = jest.fn();
const mockDisplayToast = jest.fn();
const mockUseAuth = jest.fn();
const mockUseConditionalFeature = jest.fn();
@@ -34,22 +36,25 @@ jest.mock('../../tooltip/Tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
}));
-const renderComponent = () => render( );
+const renderComponent = (channel = 'tech') =>
+ render( );
describe('HighlightCardOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAuth.mockReturnValue({ user: { id: '1' } });
mockUseConditionalFeature.mockReturnValue({ value: true });
+ mockIsChannelSubscribed.mockReturnValue(false);
mockUseMajorHeadlinesSubscription.mockReturnValue({
- isSubscribed: false,
+ isChannelSubscribed: mockIsChannelSubscribed,
isLoading: false,
- subscribe: mockSubscribe,
- unsubscribe: mockUnsubscribe,
+ isPending: false,
+ subscribeChannel: mockSubscribeChannel,
+ unsubscribeChannel: mockUnsubscribeChannel,
});
});
- it('should render bell button when feature is on and user is logged in', () => {
+ it('should render bell button when feature is on, user is logged in and channel is provided', () => {
renderComponent();
expect(
@@ -77,15 +82,27 @@ describe('HighlightCardOptions', () => {
).not.toBeInTheDocument();
});
- it('should subscribe and show toast with settings action when not subscribed', async () => {
- renderComponent();
+ 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');
fireEvent.click(
screen.getByRole('button', { name: 'Get real-time alerts' }),
);
await waitFor(() => {
- expect(mockSubscribe).toHaveBeenCalledWith('feed_card');
+ expect(mockSubscribeChannel).toHaveBeenCalledWith(
+ 'tech',
+ HighlightSignificance.Major,
+ 'feed_card',
+ );
});
await waitFor(() => {
expect(mockDisplayToast).toHaveBeenCalledWith(
@@ -114,22 +131,17 @@ describe('HighlightCardOptions', () => {
expect(mockRouterPush).toHaveBeenCalledWith('/settings/notifications');
});
- it('should unsubscribe when subscribed', async () => {
- mockUseMajorHeadlinesSubscription.mockReturnValue({
- isSubscribed: true,
- isLoading: false,
- subscribe: mockSubscribe,
- unsubscribe: mockUnsubscribe,
- });
+ it('should unsubscribe when channel is already subscribed', async () => {
+ mockIsChannelSubscribed.mockReturnValue(true);
- renderComponent();
+ renderComponent('tech');
fireEvent.click(
screen.getByRole('button', { name: 'Turn off real-time alerts' }),
);
await waitFor(() => {
- expect(mockUnsubscribe).toHaveBeenCalledWith('feed_card');
+ expect(mockUnsubscribeChannel).toHaveBeenCalledWith('tech', '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 a0d9416689b..92fe3a1e330 100644
--- a/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
+++ b/packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
@@ -10,34 +10,44 @@ 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): ReactElement => {
+}: HighlightCardOptionsProps & { channel: string }): ReactElement => {
const router = useRouter();
- const [isPending, setIsPending] = useState(false);
+ const [isToggling, setIsToggling] = useState(false);
const { displayToast } = useToastNotification();
- const { isSubscribed, isLoading, subscribe, unsubscribe } =
- useMajorHeadlinesSubscription();
+ const {
+ isChannelSubscribed,
+ isLoading,
+ isPending,
+ subscribeChannel,
+ unsubscribeChannel,
+ } = useMajorHeadlinesSubscription();
+
+ const isSubscribed = isChannelSubscribed(channel);
const handleToggle = async () => {
- if (isPending || isLoading) {
+ if (isToggling || isLoading || isPending) {
return;
}
- setIsPending(true);
+ setIsToggling(true);
try {
if (isSubscribed) {
- await unsubscribe('feed_card');
+ await unsubscribeChannel(channel, 'feed_card');
displayToast('Real-time alerts turned off.');
return;
}
- await subscribe('feed_card');
+ await subscribeChannel(channel, HighlightSignificance.Major, 'feed_card');
displayToast("You'll be the first to know when news breaks.", {
action: {
copy: 'Settings',
@@ -45,7 +55,7 @@ const HighlightCardOptionsContent = ({
},
});
} finally {
- setIsPending(false);
+ setIsToggling(false);
}
};
@@ -67,13 +77,14 @@ const HighlightCardOptionsContent = ({
)}
aria-label={label}
onClick={handleToggle}
- disabled={isPending || isLoading}
+ disabled={isToggling || isLoading || isPending}
/>
);
};
export const HighlightCardOptions = ({
+ channel,
className,
}: HighlightCardOptionsProps): ReactElement | null => {
const auth = useAuthContext();
@@ -83,11 +94,13 @@ export const HighlightCardOptions = ({
shouldEvaluate: !!user,
});
- if (!isFeatureEnabled || !user) {
+ if (!isFeatureEnabled || !user || !channel) {
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 b1f5d93dfa3..ef0478eadc9 100644
--- a/packages/shared/src/components/cards/highlight/common.tsx
+++ b/packages/shared/src/components/cards/highlight/common.tsx
@@ -80,7 +80,10 @@ 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 26862f9dd93..b6c945a4133 100644
--- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx
+++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.spec.tsx
@@ -3,9 +3,10 @@ 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 mockSubscribe = jest.fn().mockResolvedValue(undefined);
+const mockSubscribeAll = jest.fn().mockResolvedValue(undefined);
const mockCompleteAction = jest.fn().mockResolvedValue(undefined);
const mockCheckHasCompleted = jest.fn();
const mockDisplayToast = jest.fn();
@@ -53,22 +54,25 @@ 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({
- isSubscribed: false,
- isLoading: false,
- subscribe: mockSubscribe,
- unsubscribe: jest.fn(),
- });
+ mockUseMajorHeadlinesSubscription.mockReturnValue(defaultHookReturn());
mockCheckHasCompleted.mockReturnValue(false);
mockUsePushNotificationContext.mockReturnValue({ isSubscribed: false });
});
- it('should render banner when feature is on, user is logged in, not subscribed and not dismissed', () => {
+ it('should render banner when feature is on, user is logged in, no channels subscribed and not dismissed', () => {
renderComponent();
expect(screen.getByText('Push notifications')).toBeInTheDocument();
@@ -91,13 +95,10 @@ describe('EnableHighlightsAlerts', () => {
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(),
- });
+ it('should not render when any channel is already subscribed', () => {
+ mockUseMajorHeadlinesSubscription.mockReturnValue(
+ defaultHookReturn({ isAnyChannelSubscribed: true }),
+ );
renderComponent();
@@ -121,13 +122,16 @@ describe('EnableHighlightsAlerts', () => {
});
});
- it('should subscribe and show toast with settings action on CTA click when push is not yet enabled', async () => {
+ it('should subscribe all channels at Major+ and show toast on CTA click when push is not yet enabled', async () => {
renderComponent();
fireEvent.click(screen.getByText('Enable notifications'));
await waitFor(() => {
- expect(mockSubscribe).toHaveBeenCalledWith('highlights_page');
+ expect(mockSubscribeAll).toHaveBeenCalledWith(
+ HighlightSignificance.Major,
+ 'highlights_page',
+ );
});
await waitFor(() => {
@@ -152,7 +156,10 @@ describe('EnableHighlightsAlerts', () => {
fireEvent.click(screen.getByText('Enable notifications'));
await waitFor(() => {
- expect(mockSubscribe).toHaveBeenCalledWith('highlights_page');
+ expect(mockSubscribeAll).toHaveBeenCalledWith(
+ HighlightSignificance.Major,
+ 'highlights_page',
+ );
});
await waitFor(() => {
diff --git a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx
index b6dad8c02a4..9c7b29d7392 100644
--- a/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx
+++ b/packages/shared/src/components/highlights/EnableHighlightsAlerts.tsx
@@ -25,6 +25,7 @@ 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;
@@ -42,9 +43,9 @@ export const EnableHighlightsAlerts = ({
const { checkHasCompleted, completeAction, isActionsFetched } = useActions();
const { logEvent } = useLogContext();
const { displayToast } = useToastNotification();
- const { isSubscribed, isLoading, subscribe } =
+ const { isAnyChannelSubscribed, isLoading, isPending, subscribeAll } =
useMajorHeadlinesSubscription();
- const [isPending, setIsPending] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
const [acceptedJustNow, setAcceptedJustNow] = useState(false);
const { value: isFeatureEnabled } = useConditionalFeature({
@@ -60,7 +61,7 @@ export const EnableHighlightsAlerts = ({
isFeatureEnabled &&
!!user &&
isActionsFetched &&
- !isSubscribed &&
+ !isAnyChannelSubscribed &&
!isDismissed;
useLogEventOnce(
@@ -72,12 +73,12 @@ export const EnableHighlightsAlerts = ({
);
const handleEnable = useCallback(async () => {
- if (isPending || isLoading) {
+ if (isSubmitting || isLoading || isPending) {
return;
}
- setIsPending(true);
+ setIsSubmitting(true);
try {
- await subscribe(ORIGIN);
+ await subscribeAll(HighlightSignificance.Major, ORIGIN);
if (isPushEnabled) {
setAcceptedJustNow(true);
return;
@@ -89,9 +90,17 @@ export const EnableHighlightsAlerts = ({
},
});
} finally {
- setIsPending(false);
+ setIsSubmitting(false);
}
- }, [isPending, isLoading, subscribe, isPushEnabled, displayToast, router]);
+ }, [
+ isSubmitting,
+ isLoading,
+ isPending,
+ subscribeAll,
+ isPushEnabled,
+ displayToast,
+ router,
+ ]);
const handleDismiss = useCallback(() => {
completeAction(ActionType.DismissedMajorHeadlinesAlertsBanner);
@@ -156,7 +165,7 @@ export const EnableHighlightsAlerts = ({
color={ButtonColor.Cabbage}
className="mr-4"
onClick={handleEnable}
- disabled={isPending || isLoading}
+ disabled={isSubmitting || isLoading || isPending}
>
Enable notifications
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'}