• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / de78a40c-1b6b-4b1a-98c3-56eb1add96d7

22 Jul 2025 07:58PM UTC coverage: 86.268% (-0.02%) from 86.285%
de78a40c-1b6b-4b1a-98c3-56eb1add96d7

Pull #5742

circleci

joeherm
MPP-4033: Fix Relay user replies to be case insensitve to their stored email
Pull Request #5742: MPP-4033: Fix Relay user replies to be case insensitve to their stored email

2729 of 3936 branches covered (69.33%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 2 files covered. (100.0%)

42 existing lines in 5 files now uncovered.

17851 of 19920 relevant lines covered (89.61%)

9.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.12
/frontend/src/components/layout/navigation/whatsnew/WhatsNewMenu.tsx
1
import {
2
  forwardRef,
3
  HTMLAttributes,
4
  ReactNode,
5
  RefObject,
6
  useRef,
7
} from "react";
9✔
8
import {
9
  DismissButton,
10
  FocusScope,
11
  mergeProps,
12
  OverlayContainer,
13
  useButton,
14
  useDialog,
15
  useModal,
16
  useOverlay,
17
  useOverlayPosition,
18
  useOverlayTrigger,
19
} from "react-aria";
9✔
20
import { useOverlayTriggerState } from "react-stately";
9✔
21
import { StaticImageData } from "next/image";
22
import styles from "./WhatsNewMenu.module.scss";
9✔
23
import SizeLimitHero from "./images/size-limit-hero-10mb.svg";
9✔
24
import SizeLimitIcon from "./images/size-limit-icon-10mb.svg";
9✔
25
import SignBackInHero from "./images/sign-back-in-hero.svg";
9✔
26
import SignBackInIcon from "./images/sign-back-in-icon.svg";
9✔
27
import ForwardSomeHero from "./images/forward-some-hero.svg";
9✔
28
import ForwardSomeIcon from "./images/forward-some-icon.svg";
9✔
29
import aliasToMaskHero from "./images/alias-to-mask-hero.svg";
9✔
30
import aliasToMaskIcon from "./images/alias-to-mask-icon.svg";
9✔
31
import TrackerRemovalHero from "./images/tracker-removal-hero.svg";
9✔
32
import TrackerRemovalIcon from "./images/tracker-removal-icon.svg";
9✔
33
import PremiumSwedenHero from "./images/premium-expansion-sweden-hero.svg";
9✔
34
import PremiumSwedenIcon from "./images/premium-expansion-sweden-icon.svg";
9✔
35
import PremiumEuExpansionHero from "./images/eu-expansion-hero.svg";
9✔
36
import PremiumEuExpansionIcon from "./images/eu-expansion-icon.svg";
9✔
37
import PremiumFinlandHero from "./images/premium-expansion-finland-hero.svg";
9✔
38
import PremiumFinlandIcon from "./images/premium-expansion-finland-icon.svg";
9✔
39
import PhoneMaskingHero from "./images/phone-masking-hero.svg";
9✔
40
import PhoneMaskingIcon from "./images/phone-masking-icon.svg";
9✔
41
import HolidayPromo2023Icon from "./images/holiday-promo-2023-news-icon.svg";
9✔
42
import HolidayPromo2023Hero from "./images/holiday-promo-2023-news-hero.svg";
9✔
43
import BundleHero from "./images/bundle-promo-hero.svg";
9✔
44
import BundleIcon from "./images/bundle-promo-icon.svg";
9✔
45
import FirefoxIntegrationHero from "./images/firefox-integration-hero.svg";
9✔
46
import FirefoxIntegrationIcon from "./images/firefox-integration-icon.svg";
9✔
47
import MailingListHero from "./images/mailing-list-hero.svg";
9✔
48
import MailingListIcon from "./images/mailing-list-icon.svg";
9✔
49
import ShieldHero from "./images/shield-hero.svg";
9✔
50
import ShieldIcon from "./images/shield-icon.svg";
9✔
51
import { WhatsNewContent } from "./WhatsNewContent";
9✔
52
import {
53
  DismissalData,
54
  useLocalDismissal,
55
} from "../../../../hooks/localDismissal";
9✔
56
import { ProfileData } from "../../../../hooks/api/profile";
57
import { WhatsNewDashboard } from "./WhatsNewDashboard";
9✔
58
import { useAddonData } from "../../../../hooks/addon";
9✔
59
import { isUsingFirefox } from "../../../../functions/userAgent";
9✔
60
import { getLocale } from "../../../../functions/getLocale";
9✔
61
import { RuntimeData } from "../../../../hooks/api/runtimeData";
62
import { isFlagActive } from "../../../../functions/waffle";
9✔
63
import {
64
  getBundlePrice,
65
  getMegabundlePrice,
66
  getMegabundleSubscribeLink,
67
  getPeriodicalPremiumSubscribeLink,
68
  isBundleAvailableInCountry,
69
  isMegabundleAvailableInCountry,
70
  isPeriodicalPremiumAvailableInCountry,
71
  isPhonesAvailableInCountry,
72
} from "../../../../functions/getPlan";
9✔
73
import Link from "next/link";
9✔
74
import { GiftIcon } from "../../../Icons";
9✔
75
import { useGaEvent } from "../../../../hooks/gaEvent";
9✔
76
import { useL10n } from "../../../../hooks/l10n";
9✔
77
import { VisuallyHidden } from "../../../VisuallyHidden";
9✔
78
import { useOverlayBugWorkaround } from "../../../../hooks/overlayBugWorkaround";
9✔
79
import { useGaViewPing } from "../../../../hooks/gaViewPing";
9✔
80

81
export type WhatsNewEntry = {
82
  title: string;
83
  snippet: string;
84
  content: ReactNode;
85
  icon: StaticImageData;
86
  dismissal: DismissalData;
87
  /**
88
   * This is used to automatically archive entries of a certain age
89
   */
90
  announcementDate: {
91
    year: number;
92
    // Spelled out just to make sure it's clear we're not using 0-based months.
93
    // Thanks, JavaScript...
94
    month: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
95
    day: number;
96
  };
97
};
98

99
export type Props = {
100
  profile: ProfileData;
101
  style: string;
102
  runtimeData?: RuntimeData;
103
};
104

105
type CtaProps = {
106
  link?: string;
107
  label: string;
108
  subscribed?: boolean;
109
};
110

111
const CtaLinkButton = (props: CtaProps) => {
9✔
112
  const hasSubscription = props.subscribed;
×
113

114
  return (
115
    <>
116
      {!hasSubscription ? (
×
117
        <Link href="/premium#pricing" legacyBehavior>
118
          <span className={styles.cta}>{props.label}</span>
119
        </Link>
120
      ) : null}
121
    </>
122
  );
123
};
124

125
export const WhatsNewMenu = (props: Props) => {
79✔
126
  const l10n = useL10n();
103✔
127
  const gaEvent = useGaEvent();
103✔
128

129
  const triggerState = useOverlayTriggerState({
103✔
130
    onOpenChange(isOpen) {
131
      gaEvent({
1✔
132
        category: "News",
133
        action: isOpen ? "Open" : "Close",
1!
134
        label: "header-nav",
135
      });
136
    },
137
  });
138

139
  const triggerRef = useRef<HTMLButtonElement>(null);
103✔
140
  const overlayRef = useRef<HTMLDivElement>(null);
103✔
141
  const addonData = useAddonData();
103✔
142

143
  const entries: WhatsNewEntry[] = [
103✔
144
    {
145
      title: l10n.getString("whatsnew-feature-size-limit-heading"),
146
      snippet: l10n.getString("whatsnew-feature-size-limit-snippet-var", {
147
        size: 10,
148
        unit: "MB",
149
      }),
150
      content: (
151
        <WhatsNewContent
152
          description={l10n.getString(
153
            "whatsnew-feature-size-limit-description-var",
154
            {
155
              size: 10,
156
              unit: "MB",
157
            },
158
          )}
159
          heading={l10n.getString("whatsnew-feature-size-limit-heading")}
160
          image={SizeLimitHero}
161
          videos={{
162
            // Unfortunately video files cannot currently be imported, so make
163
            // sure these files are present in /public. See
164
            // https://github.com/vercel/next.js/issues/35248
165
            "video/webm; codecs='vp9'":
166
              "/animations/whatsnew/size-limit-hero-10mb.webm",
167
            "video/mp4": "/animations/whatsnew/size-limit-hero-10mb.mp4",
168
          }}
169
        />
170
      ),
171
      icon: SizeLimitIcon,
172
      dismissal: useLocalDismissal(
173
        `whatsnew-feature_size-limit_${props.profile.id}`,
174
      ),
175
      announcementDate: {
176
        year: 2022,
177
        month: 3,
178
        day: 1,
179
      },
180
    },
181
  ];
182

183
  const forwardSomeEntry: WhatsNewEntry = {
103✔
184
    title: l10n.getString("whatsnew-feature-forward-some-heading"),
185
    snippet: l10n.getString("whatsnew-feature-forward-some-snippet"),
186
    content: (
187
      <WhatsNewContent
188
        description={l10n.getString(
189
          "whatsnew-feature-forward-some-description",
190
        )}
191
        heading={l10n.getString("whatsnew-feature-forward-some-heading")}
192
        image={ForwardSomeHero}
193
      />
194
    ),
195
    icon: ForwardSomeIcon,
196
    dismissal: useLocalDismissal(
197
      `whatsnew-feature_sign-back-in_${props.profile.id}`,
198
    ),
199
    announcementDate: {
200
      year: 2022,
201
      month: 3,
202
      day: 1,
203
    },
204
  };
205
  if (props.profile.has_premium) {
103✔
206
    entries.push(forwardSomeEntry);
6✔
207
  }
208

209
  const signBackInEntry: WhatsNewEntry = {
103✔
210
    title: l10n.getString("whatsnew-feature-sign-back-in-heading"),
211
    snippet: l10n.getString("whatsnew-feature-sign-back-in-snippet"),
212
    content: (
213
      <WhatsNewContent
214
        description={l10n.getString(
215
          "whatsnew-feature-sign-back-in-description",
216
        )}
217
        heading={l10n.getString("whatsnew-feature-sign-back-in-heading")}
218
        image={SignBackInHero}
219
      />
220
    ),
221
    icon: SignBackInIcon,
222
    dismissal: useLocalDismissal(
223
      `whatsnew-feature_sign-back-in_${props.profile.id}`,
224
    ),
225
    announcementDate: {
226
      year: 2022,
227
      month: 2,
228
      day: 1,
229
    },
230
  };
231
  if (addonData.present && isUsingFirefox()) {
103!
232
    entries.push(signBackInEntry);
×
233
  }
234

235
  const aliasToMask: WhatsNewEntry = {
103✔
236
    title: l10n.getString("whatsnew-feature-alias-to-mask-heading"),
237
    snippet: l10n.getString("whatsnew-feature-alias-to-mask-snippet"),
238
    content: (
239
      <WhatsNewContent
240
        description={l10n.getString(
241
          "whatsnew-feature-alias-to-mask-description",
242
        )}
243
        heading={l10n.getString("whatsnew-feature-alias-to-mask-heading")}
244
        image={aliasToMaskHero}
245
      />
246
    ),
247
    icon: aliasToMaskIcon,
248
    dismissal: useLocalDismissal(
249
      `whatsnew-feature_alias-to-mask_${props.profile.id}`,
250
    ),
251
    announcementDate: {
252
      year: 2022,
253
      month: 4,
254
      day: 19,
255
    },
256
  };
257
  // Not all localisations transitioned from "alias" to "mask", so only show this
258
  // announcement for those of which we _know_ did:
259
  if (
103!
260
    [
261
      "en",
262
      "en-gb",
263
      "nl",
264
      "fy-nl",
265
      "zh-tw",
266
      "es-es",
267
      "es-mx",
268
      "de",
269
      "pt-br",
270
      "sv-se",
271
      "el",
272
      "hu",
273
      "sk",
274
      "skr",
275
      "uk",
276
    ].includes(getLocale(l10n).toLowerCase())
277
  ) {
278
    entries.push(aliasToMask);
103✔
279
  }
280

281
  const premiumInSweden: WhatsNewEntry = {
103✔
282
    title: l10n.getString("whatsnew-feature-premium-expansion-sweden-heading"),
283
    snippet: l10n.getString("whatsnew-feature-premium-expansion-snippet"),
284
    content: (
285
      <WhatsNewContent
286
        description={l10n.getString(
287
          "whatsnew-feature-premium-expansion-description",
288
        )}
289
        heading={l10n.getString(
290
          "whatsnew-feature-premium-expansion-sweden-heading",
291
        )}
292
        image={PremiumSwedenHero}
293
      />
294
    ),
295
    icon: PremiumSwedenIcon,
296
    dismissal: useLocalDismissal(
297
      `whatsnew-feature_premium-expansion-sweden_${props.profile.id}`,
298
    ),
299
    announcementDate: {
300
      year: 2022,
301
      month: 5,
302
      day: 17,
303
    },
304
  };
305
  if (
103!
306
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
103!
307
      "se" &&
308
    !props.profile.has_premium
309
  ) {
310
    entries.push(premiumInSweden);
×
311
  }
312

313
  const premiumInFinland: WhatsNewEntry = {
103✔
314
    title: l10n.getString("whatsnew-feature-premium-expansion-finland-heading"),
315
    snippet: l10n.getString("whatsnew-feature-premium-expansion-snippet"),
316
    content: (
317
      <WhatsNewContent
318
        description={l10n.getString(
319
          "whatsnew-feature-premium-expansion-description",
320
        )}
321
        heading={l10n.getString(
322
          "whatsnew-feature-premium-expansion-finland-heading",
323
        )}
324
        image={PremiumFinlandHero}
325
      />
326
    ),
327
    icon: PremiumFinlandIcon,
328
    dismissal: useLocalDismissal(
329
      `whatsnew-feature_premium-expansion-finland_${props.profile.id}`,
330
    ),
331
    announcementDate: {
332
      year: 2022,
333
      month: 5,
334
      day: 17,
335
    },
336
  };
337
  if (
103!
338
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
103!
339
      "fi" &&
340
    !props.profile.has_premium
341
  ) {
342
    entries.push(premiumInFinland);
×
343
  }
344

345
  // Check if yearlyPlanLink should be generated based on runtimeData and availability
346
  const yearlyPlanLink =
347
    props.runtimeData &&
103✔
348
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
349
      ? getPeriodicalPremiumSubscribeLink(props.runtimeData, "yearly")
350
      : undefined;
351

352
  const yearlyPlanRefWithCoupon = `${yearlyPlanLink}&coupon=HOLIDAY20&utm_source=relay.firefox.com&utm_medium=whatsnew-announcement&utm_campaign=relay-holiday-promo-2023`;
103✔
353
  const getYearlyPlanBtnRef = useGaViewPing({
103✔
354
    category: "Holiday Promo News CTA",
355
    label: "holiday-promo-2023-news-cta",
356
  });
357

358
  const holidayPromo2023: WhatsNewEntry = {
103✔
359
    title: l10n.getString("whatsnew-holiday-promo-2023-news-heading"),
360
    snippet: l10n.getString("whatsnew-holiday-promo-2023-news-snippet"),
361
    content: (
362
      <WhatsNewContent
363
        description={l10n.getString(
364
          "whatsnew-holiday-promo-2023-news-content-description",
365
        )}
366
        heading={l10n.getString("whatsnew-holiday-promo-2023-news-heading")}
367
        image={HolidayPromo2023Hero}
368
        cta={
369
          <Link
370
            href={yearlyPlanRefWithCoupon}
371
            ref={getYearlyPlanBtnRef}
372
            onClick={() => {
373
              gaEvent({
×
374
                category: "Holiday Promo News CTA",
375
                action: "Engage",
376
                label: "holiday-promo-2023-news-cta",
377
              });
378
            }}
379
          >
380
            <span className={styles.cta}>
381
              {l10n.getString("whatsnew-holiday-promo-2023-cta")}
382
            </span>
383
          </Link>
384
        }
385
      />
386
    ),
387
    icon: HolidayPromo2023Icon,
388
    dismissal: useLocalDismissal(
389
      `whatsnew-holiday_promo_2023_${props.profile.id}`,
390
    ),
391
    announcementDate: {
392
      year: 2023,
393
      month: 11,
394
      day: 29,
395
    },
396
  };
397

398
  // Check if the holiday promotion entry should be added to the entries array
399
  if (
103!
400
    isFlagActive(props.runtimeData, "holiday_promo_2023") &&
109!
401
    !props.profile.has_premium &&
402
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
403
  ) {
404
    entries.push(holidayPromo2023);
×
405
  }
406

407
  const premiumEuExpansion: WhatsNewEntry = {
103✔
408
    title: l10n.getString("whatsnew-feature-premium-expansion-eu-heading"),
409
    snippet: l10n.getString("whatsnew-feature-premium-expansion-eu-snippet"),
410
    content: (
411
      <WhatsNewContent
412
        description={l10n.getString(
413
          "whatsnew-feature-premium-expansion-eu-description",
414
        )}
415
        heading={l10n.getString(
416
          "whatsnew-feature-premium-expansion-eu-heading",
417
        )}
418
        image={PremiumEuExpansionHero}
419
        cta={
420
          <Link href="/premium#pricing" legacyBehavior>
421
            <span className={styles.cta}>
422
              {l10n.getString("whatsnew-feature-premium-expansion-eu-cta")}
423
            </span>
424
          </Link>
425
        }
426
      />
427
    ),
428
    icon: PremiumEuExpansionIcon,
429
    dismissal: useLocalDismissal(
430
      `whatsnew-feature_premium-eu-expansion_2023_${props.profile.id}`,
431
    ),
432
    announcementDate: {
433
      year: 2023,
434
      month: 7,
435
      day: 26,
436
    },
437
  };
438

439
  if (
103!
440
    typeof props.runtimeData !== "undefined" &&
303✔
441
    !props.profile.has_premium &&
442
    [
443
      "bg",
444
      "cz",
445
      "cy",
446
      "dk",
447
      "ee",
448
      "gr",
449
      "hr",
450
      "hu",
451
      "lt",
452
      "lv",
453
      "lu",
454
      "mt",
455
      "pl",
456
      "pt",
457
      "ro",
458
      "si",
459
      "sk",
460
    ].includes(
461
      props.runtimeData.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase(),
462
    )
463
  ) {
464
    entries.push(premiumEuExpansion);
×
465
  }
466

467
  const trackerRemoval: WhatsNewEntry = {
103✔
468
    title: l10n.getString("whatsnew-feature-tracker-removal-heading"),
469
    snippet: l10n.getString("whatsnew-feature-tracker-removal-snippet"),
470
    content: (
471
      <WhatsNewContent
472
        description={l10n.getString(
473
          "whatsnew-feature-tracker-removal-description-2",
474
        )}
475
        heading={l10n.getString("whatsnew-feature-tracker-removal-heading")}
476
        image={TrackerRemovalHero}
477
      />
478
    ),
479
    icon: TrackerRemovalIcon,
480
    dismissal: useLocalDismissal(
481
      `whatsnew-feature_tracker-removal_${props.profile.id}`,
482
    ),
483
    announcementDate: {
484
      year: 2022,
485
      month: 8,
486
      day: 16,
487
    },
488
  };
489
  // Only show its announcement if tracker removal is live:
490
  if (isFlagActive(props.runtimeData, "tracker_removal")) {
103✔
491
    entries.push(trackerRemoval);
6✔
492
  }
493

494
  const phoneAnnouncement: WhatsNewEntry = {
103✔
495
    title: l10n.getString("whatsnew-feature-phone-header"),
496
    snippet: l10n.getString("whatsnew-feature-phone-snippet"),
497
    content:
498
      props.runtimeData && isPhonesAvailableInCountry(props.runtimeData) ? (
309✔
499
        <WhatsNewContent
500
          description={l10n.getString("whatsnew-feature-phone-description")}
501
          heading={l10n.getString("whatsnew-feature-phone-header")}
502
          image={PhoneMaskingHero}
503
          videos={{
504
            // Unfortunately video files cannot currently be imported, so make
505
            // sure these files are present in /public. See
506
            // https://github.com/vercel/next.js/issues/35248
507
            "video/webm; codecs='vp9'":
508
              "/animations/whatsnew/phone-masking-hero.webm",
509
            "video/mp4": "/animations/whatsnew/phone-masking-hero.mp4",
510
          }}
511
          cta={
512
            <CtaLinkButton
513
              subscribed={props.profile.has_phone}
514
              label={l10n.getString("whatsnew-feature-phone-upgrade-cta")}
515
            />
516
          }
517
        />
518
      ) : null,
519

520
    icon: PhoneMaskingIcon,
521
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
522
    announcementDate: {
523
      year: 2022,
524
      month: 10,
525
      day: 11,
526
    },
527
  };
528

529
  // Only show its announcement if phone masking is live:
530
  if (isPhonesAvailableInCountry(props.runtimeData)) {
103✔
531
    entries.push(phoneAnnouncement);
6✔
532
  }
533

534
  const vpnAndRelayAnnouncement: WhatsNewEntry = {
103✔
535
    title: l10n.getString("whatsnew-feature-bundle-header-2", {
536
      savings: "40%",
537
    }),
538
    snippet: l10n.getString("whatsnew-feature-bundle-snippet-2"),
539
    content:
540
      props.runtimeData && isBundleAvailableInCountry(props.runtimeData) ? (
309✔
541
        <WhatsNewContent
542
          description={l10n.getString("whatsnew-feature-bundle-body-v2", {
543
            monthly_price: getBundlePrice(props.runtimeData, l10n),
544
            savings: "40%",
545
          })}
546
          heading={l10n.getString("whatsnew-feature-bundle-header-2", {
547
            savings: "40%",
548
          })}
549
          image={BundleHero}
550
          videos={{
551
            // Unfortunately video files cannot currently be imported, so make
552
            // sure these files are present in /public. See
553
            // https://github.com/vercel/next.js/issues/35248
554
            "video/webm; codecs='vp9'":
555
              "/animations/whatsnew/bundle-promo-hero.webm",
556
            "video/mp4": "/animations/whatsnew/bundle-promo-hero.mp4",
557
          }}
558
          cta={
559
            <CtaLinkButton
560
              // TODO: Add has_bundle to profile data => subscribed={props.profile.has_bundle}
561
              label={l10n.getString("whatsnew-feature-bundle-upgrade-cta")}
562
            />
563
          }
564
        />
565
      ) : null,
566

567
    icon: BundleIcon,
568
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
569
    announcementDate: {
570
      year: 2022,
571
      month: 10,
572
      day: 11,
573
    },
574
  };
575

576
  // Only show its announcement if bundle is live:
577
  if (isBundleAvailableInCountry(props.runtimeData)) {
103✔
578
    entries.push(vpnAndRelayAnnouncement);
6✔
579
  }
580

581
  const firefoxIntegrationAnnouncement: WhatsNewEntry = {
103✔
582
    title: l10n.getString("whatsnew-feature-firefox-integration-heading"),
583
    snippet: l10n.getString("whatsnew-feature-firefox-integration-snippet"),
584
    content: (
585
      <WhatsNewContent
586
        description={l10n.getString(
587
          "whatsnew-feature-firefox-integration-description",
588
        )}
589
        heading={l10n.getString("whatsnew-feature-firefox-integration-heading")}
590
        image={FirefoxIntegrationHero}
591
      />
592
    ),
593
    icon: FirefoxIntegrationIcon,
594
    dismissal: useLocalDismissal(
595
      `whatsnew-feature_firefox-integration_${props.profile.id}`,
596
    ),
597
    // Week after release of Firefox 111 (to ensure it was rolled out to everyone)
598
    announcementDate: {
599
      year: 2023,
600
      month: 3,
601
      day: 21,
602
    },
603
  };
604
  if (
103!
605
    isFlagActive(props.runtimeData, "firefox_integration") &&
109✔
606
    isUsingFirefox()
607
  ) {
608
    entries.push(firefoxIntegrationAnnouncement);
×
609
  }
610

611
  const mailingListAnnouncement: WhatsNewEntry = {
103✔
612
    title: l10n.getString("whatsnew-feature-mailing-list-heading"),
613
    snippet: l10n.getString("whatsnew-feature-mailing-list-snippet"),
614
    content: (
615
      <WhatsNewContent
616
        description={l10n.getString(
617
          "whatsnew-feature-mailing-list-description",
618
        )}
619
        heading={l10n.getString("whatsnew-feature-mailing-list-heading")}
620
        image={MailingListHero}
621
        cta={
622
          <a
623
            className={styles.cta}
624
            href="https://www.mozilla.org/newsletter/security-and-privacy/"
625
            target="_blank"
626
          >
627
            {l10n.getString("whatsnew-feature-mailing-list-cta")}
628
          </a>
629
        }
630
      />
631
    ),
632
    icon: MailingListIcon,
633
    dismissal: useLocalDismissal(
634
      `whatsnew-feature_mailing-list_${props.profile.id}`,
635
    ),
636
    announcementDate: {
637
      year: 2023,
638
      month: 6,
639
      day: 3,
640
    },
641
  };
642

643
  if (isFlagActive(props.runtimeData, "mailing_list_announcement")) {
103✔
644
    entries.push(mailingListAnnouncement);
6✔
645
  }
646

647
  const megabundleDismissal = useLocalDismissal(
103✔
648
    `whatsnew-megabundle_${props.profile.id}`,
649
  );
650

651
  if (
103✔
652
    isMegabundleAvailableInCountry(props.runtimeData) &&
218✔
653
    props.profile.has_premium &&
654
    !props.profile.has_phone &&
655
    !props.profile.has_vpn
656
  ) {
657
    const isPremium = isPeriodicalPremiumAvailableInCountry(props.runtimeData);
6✔
658

659
    const snippet = l10n.getString(
6✔
660
      isPremium
6!
661
        ? "whatsnew-megabundle-premium-snippet"
662
        : "whatsnew-megabundle-snippet",
663
      { monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
664
    );
665

666
    const description = l10n.getString(
6✔
667
      isPremium
6!
668
        ? "whatsnew-megabundle-premium-description"
669
        : "whatsnew-megabundle-description",
670
      { monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
671
    );
672

673
    const ctaText = l10n.getString(
6✔
674
      isPremium ? "whatsnew-megabundle-premium-cta" : "whatsnew-megabundle-cta",
6!
675
    );
676

677
    const megabundleEntry: WhatsNewEntry = {
6✔
678
      title: l10n.getString("whatsnew-megabundle-heading"),
679
      snippet,
680
      content: (
681
        <WhatsNewContent
682
          heading={l10n.getString("whatsnew-megabundle-heading")}
683
          description={description}
684
          image={ShieldHero}
685
          cta={
686
            <a
687
              className={styles.cta}
688
              href={getMegabundleSubscribeLink(props.runtimeData)}
689
              target="_blank"
690
            >
691
              {ctaText}
692
            </a>
693
          }
694
        />
695
      ),
696
      icon: ShieldIcon,
697
      dismissal: megabundleDismissal,
698
      announcementDate: {
699
        year: 2025,
700
        month: 6,
701
        day: 3,
702
      },
703
    };
704

705
    entries.push(megabundleEntry);
6✔
706
  }
707

708
  const entriesNotInFuture = entries.filter((entry) => {
103✔
709
    const entryDate = new Date(
242✔
710
      Date.UTC(
711
        entry.announcementDate.year,
712
        entry.announcementDate.month - 1,
713
        entry.announcementDate.day,
714
      ),
715
    );
716
    // Filter out entries that are in the future:
717
    return entryDate.getTime() <= Date.now();
242✔
718
  });
719
  entriesNotInFuture.sort(entriesDescByDateSorter);
103✔
720

721
  const newEntries = entriesNotInFuture.filter((entry) => {
103✔
722
    const entryDate = new Date(
242✔
723
      Date.UTC(
724
        entry.announcementDate.year,
725
        entry.announcementDate.month - 1,
726
        entry.announcementDate.day,
727
      ),
728
    );
729
    const ageInMilliSeconds = Date.now() - entryDate.getTime();
242✔
730
    // Automatically move entries to the archive after 30 days:
731
    const isExpired = ageInMilliSeconds > 30 * 24 * 60 * 60 * 1000;
242✔
732
    return !entry.dismissal.isDismissed && !isExpired;
242✔
733
  });
734

735
  const { triggerProps, overlayProps } = useOverlayTrigger(
103✔
736
    { type: "dialog" },
737
    triggerState,
738
    triggerRef,
739
  );
740

741
  const positionProps = useOverlayPosition({
103✔
742
    targetRef: triggerRef,
743
    overlayRef: overlayRef,
744
    placement: "bottom end",
745
    offset: 10,
746
    isOpen: triggerState.isOpen,
747
  }).overlayProps;
748
  const overlayBugWorkaround = useOverlayBugWorkaround(triggerState);
103✔
749

750
  const { buttonProps } = useButton(triggerProps, triggerRef);
103✔
751

752
  if (entriesNotInFuture.length === 0) {
103!
UNCOV
753
    return null;
×
754
  }
755

756
  const pill =
757
    newEntries.length > 0 ? (
103✔
758
      <i
759
        aria-label={l10n.getString("whatsnew-counter-label", {
760
          count: newEntries.length,
761
        })}
762
        className={styles.pill}
763
        data-testid="whatsnew-pill"
764
      >
765
        {newEntries.length}
766
      </i>
767
    ) : null;
768

769
  return (
770
    <>
771
      {overlayBugWorkaround}
772
      <button
773
        {...buttonProps}
774
        ref={triggerRef}
775
        data-testid="whatsnew-trigger"
776
        className={`${styles.trigger} ${
777
          triggerState.isOpen ? styles["is-open"] : ""
103✔
778
        } ${props.style}`}
779
      >
780
        <GiftIcon
781
          className={styles["trigger-icon"]}
782
          alt={l10n.getString("whatsnew-trigger-label")}
783
        />
784
        <span className={styles["trigger-label"]}>
785
          {l10n.getString("whatsnew-trigger-label")}
786
        </span>
787
        {pill}
788
      </button>
789
      {triggerState.isOpen && (
103✔
790
        <OverlayContainer>
791
          <WhatsNewPopover
792
            {...overlayProps}
793
            {...positionProps}
794
            ref={overlayRef}
795
            title={l10n.getString("whatsnew-trigger-label")}
796
            isOpen={triggerState.isOpen}
UNCOV
797
            onClose={() => triggerState.close()}
×
798
          >
799
            <WhatsNewDashboard
800
              new={newEntries}
801
              archive={entriesNotInFuture}
UNCOV
802
              onClose={() => triggerState.close()}
×
803
            />
804
          </WhatsNewPopover>
805
        </OverlayContainer>
806
      )}
807
    </>
808
  );
809
};
810

811
type PopoverProps = {
812
  title: string;
813
  children: ReactNode;
814
  isOpen: boolean;
815
  onClose: () => void;
816
} & HTMLAttributes<HTMLDivElement>;
817
const WhatsNewPopover = forwardRef<HTMLDivElement, PopoverProps>(
9✔
818
  ({ title, children, isOpen, onClose, ...otherProps }, ref) => {
819
    const { overlayProps } = useOverlay(
3✔
820
      {
821
        onClose: onClose,
822
        isOpen: isOpen,
823
        isDismissable: true,
824
      },
825
      ref as RefObject<HTMLDivElement>,
826
    );
827

828
    const { modalProps } = useModal();
3✔
829

830
    const { dialogProps, titleProps } = useDialog(
3✔
831
      {},
832
      ref as RefObject<HTMLDivElement>,
833
    );
834

835
    const mergedOverlayProps = mergeProps(
3✔
836
      overlayProps,
837
      dialogProps,
838
      otherProps,
839
      modalProps,
840
    );
841

842
    return (
843
      <FocusScope restoreFocus contain autoFocus>
844
        <div
845
          {...mergedOverlayProps}
846
          ref={ref}
847
          className={styles["popover-wrapper"]}
848
        >
849
          <VisuallyHidden>
850
            <h2 {...titleProps}>{title}</h2>
851
          </VisuallyHidden>
852
          {children}
853
          <DismissButton onDismiss={onClose} />
854
        </div>
855
      </FocusScope>
856
    );
857
  },
858
);
859
WhatsNewPopover.displayName = "WhatsNewPopover";
9✔
860

861
const entriesDescByDateSorter: Parameters<Array<WhatsNewEntry>["sort"]>[0] = (
9✔
862
  entryA,
863
  entryB,
864
) => {
865
  const dateANr =
866
    entryA.announcementDate.year +
205✔
867
    entryA.announcementDate.month / 100 +
868
    entryA.announcementDate.day / 10000;
869
  const dateBNr =
870
    entryB.announcementDate.year +
205✔
871
    entryB.announcementDate.month / 100 +
872
    entryB.announcementDate.day / 10000;
873

874
  return dateBNr - dateANr;
205✔
875
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc