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

mozilla / fx-private-relay / 6b43dd79-8f94-4235-acd1-94524bacef54

02 Jul 2025 04:04PM UTC coverage: 86.642% (+1.3%) from 85.326%
6b43dd79-8f94-4235-acd1-94524bacef54

Pull #5647

circleci

vpremamozilla
MPP-4153 - test(mask-management): increase frontend test coverage for Mask Management pages and components
Pull Request #5647: MPP-4153 - increase frontend test coverage for mask management

2586 of 3657 branches covered (70.71%)

Branch coverage included in aggregate %.

17846 of 19925 relevant lines covered (89.57%)

10.18 hits per line

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

86.39
/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 (isMegabundleAvailableInCountry(props.runtimeData)) {
103✔
652
    const isPremium = isPeriodicalPremiumAvailableInCountry(props.runtimeData);
103✔
653

654
    const snippet = l10n.getString(
103✔
655
      isPremium
103✔
656
        ? "whatsnew-megabundle-premium-snippet"
657
        : "whatsnew-megabundle-snippet",
658
      { monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
659
    );
660

661
    const description = l10n.getString(
103✔
662
      isPremium
103✔
663
        ? "whatsnew-megabundle-premium-description"
664
        : "whatsnew-megabundle-description",
665
      { monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
666
    );
667

668
    const ctaText = l10n.getString(
103✔
669
      isPremium ? "whatsnew-megabundle-premium-cta" : "whatsnew-megabundle-cta",
103✔
670
    );
671

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

700
    entries.push(megabundleEntry);
103✔
701
  }
702

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

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

730
  const { triggerProps, overlayProps } = useOverlayTrigger(
103✔
731
    { type: "dialog" },
732
    triggerState,
733
    triggerRef,
734
  );
735

736
  const positionProps = useOverlayPosition({
103✔
737
    targetRef: triggerRef,
738
    overlayRef: overlayRef,
739
    placement: "bottom end",
740
    offset: 10,
741
    isOpen: triggerState.isOpen,
742
  }).overlayProps;
743
  const overlayBugWorkaround = useOverlayBugWorkaround(triggerState);
103✔
744

745
  const { buttonProps } = useButton(triggerProps, triggerRef);
103✔
746

747
  if (entriesNotInFuture.length === 0) {
103!
748
    return null;
×
749
  }
750

751
  const pill =
752
    newEntries.length > 0 ? (
103!
753
      <i
754
        aria-label={l10n.getString("whatsnew-counter-label", {
755
          count: newEntries.length,
756
        })}
757
        className={styles.pill}
758
        data-testid="whatsnew-pill"
759
      >
760
        {newEntries.length}
761
      </i>
762
    ) : null;
763

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

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

823
    const { modalProps } = useModal();
3✔
824

825
    const { dialogProps, titleProps } = useDialog(
3✔
826
      {},
827
      ref as RefObject<HTMLDivElement>,
828
    );
829

830
    const mergedOverlayProps = mergeProps(
3✔
831
      overlayProps,
832
      dialogProps,
833
      otherProps,
834
      modalProps,
835
    );
836

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

856
const entriesDescByDateSorter: Parameters<Array<WhatsNewEntry>["sort"]>[0] = (
9✔
857
  entryA,
858
  entryB,
859
) => {
860
  const dateANr =
861
    entryA.announcementDate.year +
302✔
862
    entryA.announcementDate.month / 100 +
863
    entryA.announcementDate.day / 10000;
864
  const dateBNr =
865
    entryB.announcementDate.year +
302✔
866
    entryB.announcementDate.month / 100 +
867
    entryB.announcementDate.day / 10000;
868

869
  return dateBNr - dateANr;
302✔
870
};
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